The panel handed its three Repeaters snapshots of live PwNode objects, and Quickshell deletes a node the moment PipeWire drops it. Qt then dereferences that row without a null check in VDMListDelegateDataType::createMissingProperties (qqmldmlistaccessordata_p.h:222, unchanged in 6.11.2), so any teardown that emptied the graph with the panel open segfaulted the shell: a USB DAC unplugged, a Bluetooth profile switch, omarchy-restart-audio, a WirePlumber restart, or a laptop suspend. Rows now carry the node's id and name rather than the node, and each delegate resolves its own PwNode through nodeFor(), so labels, glyphs and stream volumes stay live. The name is what pins a row to the node it came from, since PipeWire reuses object ids; it is CONSTANT on PwNode for as long as the node exists. This is the projection the bluetooth panel already uses against the same crash class. Filtering the snapshot instead does not work, which is worth recording because it is the obvious thing to reach for: the node is usually alive when the snapshot is taken and dies while Qt is incubating the delegate, so nothing visible at snapshot time identifies the row that will dangle. Co-Authored-By: Codex XHigh <codex@openai.com>
287 lines
9.1 KiB
JavaScript
287 lines
9.1 KiB
JavaScript
function isPlaybackStream(node) {
|
|
if (!node || !node.isStream) return false
|
|
if (node.isSink === true) return true
|
|
|
|
var mediaClass = String(node.type || "")
|
|
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|
|
|| mediaClass.indexOf("AudioOutStream") !== -1
|
|
|| mediaClass.indexOf("Output") !== -1
|
|
}
|
|
|
|
function isAudioSource(node) {
|
|
if (!node) return false
|
|
if (node.audio) return true
|
|
|
|
var mediaClass = String(node.type || "")
|
|
return mediaClass.indexOf("Audio/Source") !== -1
|
|
|| mediaClass.indexOf("AudioSource") !== -1
|
|
|| mediaClass.indexOf("Source") !== -1
|
|
}
|
|
|
|
// Project a node list to the primitive rows a Repeater model can hold. A row
|
|
// holding the PwNode itself is a QObject that Qt dereferences without a null
|
|
// check while it builds the delegate, and PipeWire deletes nodes outright, so a
|
|
// graph teardown segfaults the shell. Primitives cannot dangle, which is what
|
|
// closes that race -- not the check below, since the node usually dies after the
|
|
// snapshot is taken. That is also why filtering the list here never worked.
|
|
//
|
|
// The name rides along because PipeWire reuses object ids: without it a row
|
|
// could resolve to whatever inherited its id. PwNode.name is CONSTANT, so it
|
|
// identifies the node it was taken from for as long as that node lives.
|
|
function nodeRow(node) {
|
|
if (!node) return null
|
|
var id = node.id
|
|
if (id === undefined || id === null) return null
|
|
return { id: id, name: String(node.name || "") }
|
|
}
|
|
|
|
function rowSnapshot(list) {
|
|
var values = list && list.length ? list : []
|
|
var rows = []
|
|
for (var i = 0; i < values.length; i++) {
|
|
var row = nodeRow(values[i])
|
|
if (row) rows.push(row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
function outputVolumeName(volume, muted) {
|
|
if (muted) return "Muted"
|
|
var p = Math.round(volume * 100)
|
|
if (p === 0) return "Silenced"
|
|
if (p >= 100) return "Concert hall"
|
|
if (p >= 85) return "Party mode"
|
|
if (p >= 70) return "Cranked up"
|
|
if (p >= 50) return "Steady groove"
|
|
if (p >= 30) return "Easy listening"
|
|
if (p >= 15) return "Murmur"
|
|
return "Whisper"
|
|
}
|
|
|
|
function parseSinkAvailability(raw) {
|
|
var next = {}
|
|
var lines = String(raw || "").split("\n")
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim()
|
|
if (!line) continue
|
|
var parts = line.split("\t")
|
|
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
|
|
}
|
|
return next
|
|
}
|
|
|
|
function friendlyDeviceLabel(text) {
|
|
var label = String(text || "").trim()
|
|
label = label.replace(/^sof-soundwire\s+/i, "")
|
|
label = label.replace(/^built-?in audio\s+/i, "")
|
|
label = label.replace(/\s+Output$/i, "")
|
|
label = label.replace(/\s+Input$/i, "")
|
|
label = label.replace(/\bMicrophones\b/g, "Microphone")
|
|
return label
|
|
}
|
|
|
|
function nodeProps(node) {
|
|
return node && node.ready && node.properties ? node.properties : {}
|
|
}
|
|
|
|
function nodeLabel(node) {
|
|
if (!node) return "Unknown"
|
|
var p = nodeProps(node)
|
|
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
|
|
if (nickname) return nickname
|
|
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
|
|
}
|
|
|
|
function isHeadphones(node) {
|
|
if (!node) return false
|
|
var p = nodeProps(node)
|
|
var blob = String([
|
|
node.name, node.description, node.nickname,
|
|
p["device.icon-name"] || "",
|
|
p["device.product.name"] || "",
|
|
p["node.description"] || "",
|
|
p["node.nick"] || ""
|
|
].join(" ")).toLowerCase()
|
|
return blob.indexOf("headphone") !== -1
|
|
|| blob.indexOf("headset") !== -1
|
|
|| blob.indexOf("earbud") !== -1
|
|
|| blob.indexOf("earphone") !== -1
|
|
|| blob.indexOf("airpod") !== -1
|
|
}
|
|
|
|
function sinkGlyph(node) {
|
|
if (!node) return ""
|
|
if (isHeadphones(node)) return ""
|
|
var p = nodeProps(node)
|
|
var blob = String([
|
|
node.name, node.description, node.nickname,
|
|
p["device.icon-name"] || "",
|
|
p["device.product.name"] || ""
|
|
].join(" ")).toLowerCase()
|
|
if (blob.indexOf("bluetooth") !== -1) return ""
|
|
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return ""
|
|
return ""
|
|
}
|
|
|
|
function sourceGlyph(node) {
|
|
if (!node) return ""
|
|
var p = nodeProps(node)
|
|
var blob = String([
|
|
node.name, node.description, node.nickname,
|
|
p["device.icon-name"] || ""
|
|
].join(" ")).toLowerCase()
|
|
if (blob.indexOf("headset") !== -1) return ""
|
|
if (blob.indexOf("bluetooth") !== -1) return ""
|
|
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return ""
|
|
return ""
|
|
}
|
|
|
|
function friendlyStreamLabel(label) {
|
|
label = String(label || "").trim()
|
|
if (!label) return ""
|
|
|
|
var known = {
|
|
"spotify": "Spotify"
|
|
}
|
|
var normalized = label.toLowerCase()
|
|
return known[normalized] || label
|
|
}
|
|
|
|
function streamLabelKey(label) {
|
|
return String(label || "").trim().toLowerCase()
|
|
}
|
|
|
|
function streamLabelIsGeneric(label) {
|
|
return streamLabelKey(label) === "audio-src"
|
|
}
|
|
|
|
function rawStreamLabel(node) {
|
|
if (!node) return ""
|
|
var p = nodeProps(node)
|
|
return p["application.name"]
|
|
|| node.description
|
|
|| p["media.name"]
|
|
|| p["node.name"]
|
|
|| node.name
|
|
}
|
|
|
|
function mprisPlayerLabel(player) {
|
|
if (!player) return ""
|
|
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
|
|
}
|
|
|
|
function mprisPlayerIsProxy(player) {
|
|
var dbusName = String(player && player.dbusName || "").toLowerCase()
|
|
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
|
|
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
|
|
}
|
|
|
|
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
|
|
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
|
|
var playerKey = streamLabelKey(playerLabel)
|
|
if (!streamKey || !playerKey) return false
|
|
return streamKey === playerKey
|
|
|| streamKey.indexOf(playerKey) !== -1
|
|
|| playerKey.indexOf(streamKey) !== -1
|
|
}
|
|
|
|
function mprisLabelsFor(players, predicate) {
|
|
var values = Array.isArray(players) ? players : []
|
|
var playingCandidates = []
|
|
var candidates = []
|
|
var playingProxyCandidates = []
|
|
var proxyCandidates = []
|
|
|
|
for (var i = 0; i < values.length; i++) {
|
|
var player = values[i]
|
|
if (!player) continue
|
|
if (!player.isPlaying && !player.canPlay) continue
|
|
|
|
var playerLabel = mprisPlayerLabel(player)
|
|
if (!playerLabel || !predicate(playerLabel)) continue
|
|
|
|
if (mprisPlayerIsProxy(player)) {
|
|
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
|
|
proxyCandidates.push(playerLabel)
|
|
} else {
|
|
if (player.isPlaying) playingCandidates.push(playerLabel)
|
|
candidates.push(playerLabel)
|
|
}
|
|
}
|
|
|
|
if (playingCandidates.length === 1) return playingCandidates[0]
|
|
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
|
|
if (candidates.length === 1) return candidates[0]
|
|
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
|
|
return ""
|
|
}
|
|
|
|
function matchingMprisStreamLabel(label, players) {
|
|
if (streamLabelIsGeneric(label)) return ""
|
|
return mprisLabelsFor(players, function(playerLabel) {
|
|
return streamRepresentsMprisPlayer(label, playerLabel)
|
|
})
|
|
}
|
|
|
|
function unmatchedMprisStreamLabel(label, players, streams) {
|
|
if (!streamLabelIsGeneric(label)) return ""
|
|
|
|
return mprisLabelsFor(players, function(playerLabel) {
|
|
var values = Array.isArray(streams) ? streams : []
|
|
for (var i = 0; i < values.length; i++) {
|
|
var stream = values[i]
|
|
var streamLabel = rawStreamLabel(stream)
|
|
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
function streamLabel(node, players, streams) {
|
|
if (!node) return "Stream"
|
|
var label = rawStreamLabel(node)
|
|
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|
|
|| unmatchedMprisStreamLabel(label, players, streams)
|
|
|| label) || "Stream"
|
|
}
|
|
|
|
function streamRepresentsPlayer(node, player, players, streams) {
|
|
if (!node || !player) return false
|
|
var playerLabel = mprisPlayerLabel(player)
|
|
if (!playerLabel) return false
|
|
|
|
var label = rawStreamLabel(node)
|
|
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
|
|
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
isPlaybackStream: isPlaybackStream,
|
|
isAudioSource: isAudioSource,
|
|
nodeRow: nodeRow,
|
|
rowSnapshot: rowSnapshot,
|
|
outputVolumeName: outputVolumeName,
|
|
parseSinkAvailability: parseSinkAvailability,
|
|
friendlyDeviceLabel: friendlyDeviceLabel,
|
|
nodeProps: nodeProps,
|
|
nodeLabel: nodeLabel,
|
|
isHeadphones: isHeadphones,
|
|
sinkGlyph: sinkGlyph,
|
|
sourceGlyph: sourceGlyph,
|
|
friendlyStreamLabel: friendlyStreamLabel,
|
|
streamLabelKey: streamLabelKey,
|
|
streamLabelIsGeneric: streamLabelIsGeneric,
|
|
rawStreamLabel: rawStreamLabel,
|
|
mprisPlayerLabel: mprisPlayerLabel,
|
|
mprisPlayerIsProxy: mprisPlayerIsProxy,
|
|
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
|
|
mprisLabelsFor: mprisLabelsFor,
|
|
matchingMprisStreamLabel: matchingMprisStreamLabel,
|
|
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
|
|
streamLabel: streamLabel,
|
|
streamRepresentsPlayer: streamRepresentsPlayer
|
|
}
|
|
}
|