diff --git a/Modules/Panels/Audio/AudioPanel.qml b/Modules/Panels/Audio/AudioPanel.qml index 31e22809f..2786a8bb4 100644 --- a/Modules/Panels/Audio/AudioPanel.qml +++ b/Modules/Panels/Audio/AudioPanel.qml @@ -517,6 +517,45 @@ SmartPanel { return result || "Unknown App"; } + // Tab / page / track label (browsers: media.title/name; players: artist+title when PW exposes it). + // Many apps (notably some Spotify builds) only set a generic media.name — then there is nothing useful to show. + readonly property string appStreamTitle: { + if (!modelData) { + return ""; + } + var props = modelData.properties; + var artist = ""; + var title = ""; + var mediaName = ""; + if (props) { + artist = (props["media.artist"] || "").trim(); + title = (props["media.title"] || "").trim(); + mediaName = (props["media.name"] || "").trim(); + } + var raw = ""; + if (title && artist) { + raw = artist + " — " + title; + } else if (title) { + raw = title; + } else if (artist) { + raw = artist; + } else if (mediaName) { + raw = mediaName; + } + if (!raw) { + raw = (modelData.description || "").trim(); + } + if (!raw) { + return ""; + } + var norm = raw.toLowerCase(); + var mainNorm = appName.trim().toLowerCase(); + if (norm === mainNorm) { + return ""; + } + return raw; + } + readonly property string appIcon: { if (!modelData) return ThemeIcons.iconFromName("application-x-executable", "application-x-executable"); @@ -619,6 +658,17 @@ SmartPanel { Layout.fillWidth: true } + NText { + visible: appBox.appStreamTitle !== "" + text: appBox.appStreamTitle + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + wrapMode: Text.NoWrap + maximumLineCount: 1 + Layout.fillWidth: true + } + RowLayout { Layout.fillWidth: true spacing: Style.marginM @@ -634,9 +684,7 @@ SmartPanel { onMoved: function (value) { if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) { appBox.nodeAudio.volume = value; - var key = AudioService.getAppKey(appBox.modelData); - if (key) - AudioService.setAppStreamVolume(key, value); + AudioService.setPanelAppStreamVolume(appBox.modelData, value); } } } @@ -663,9 +711,7 @@ SmartPanel { if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) { var newMuted = !appBox.appMuted; appBox.nodeAudio.muted = newMuted; - var key = AudioService.getAppKey(appBox.modelData); - if (key) - AudioService.setAppStreamMuted(key, newMuted); + AudioService.setPanelAppStreamMuted(appBox.modelData, newMuted); } } } diff --git a/Services/Media/AudioService.qml b/Services/Media/AudioService.qml index fffdb1c6e..9debe4c5c 100644 --- a/Services/Media/AudioService.qml +++ b/Services/Media/AudioService.qml @@ -349,8 +349,14 @@ Singleton { objects: [...root.sinks, ...root.sources] } - // Per-stream volume overrides (app + media identity) so concurrent browser streams do not share one entry. + // Per-stream volume overrides (app + media identity) so concurrent streams do not share one entry. property var appVolumeOverrides: ({}) + // Panel sticky: single stream per process base → store by base (survives track / node churn). + // Multiple streams same base → store by full stream key; base-only locks migrate when n grows. + property var panelAppVolumeByBase: ({}) + property var panelAppMutedByBase: ({}) + property var panelAppVolumeByStreamKey: ({}) + property var panelAppMutedByStreamKey: ({}) property var _knownAppStreamIds: ({}) property bool _isApplyingAppOverride: false @@ -358,7 +364,7 @@ Singleton { objects: root.streamNodes } - // Keep appVolumeOverrides aligned with PipeWire when apps change volume/mute. + // PipeWire → override sync (skipped while we are applying our own overrides). Item { width: 0 height: 0 @@ -377,6 +383,9 @@ Singleton { if (root._isApplyingAppOverride || !modelData?.audio) { return; } + if (root._skipPipewireVolumeSyncForNode(modelData)) { + return; + } var key = root.getAppKey(modelData); if (key) { root.setAppStreamVolume(key, modelData.audio.volume); @@ -387,6 +396,9 @@ Singleton { if (root._isApplyingAppOverride || !modelData?.audio) { return; } + if (root._skipPipewireMuteSyncForNode(modelData)) { + return; + } var key = root.getAppKey(modelData); if (key) { root.setAppStreamMuted(key, modelData.audio.muted); @@ -397,7 +409,7 @@ Singleton { } } - function getAppKey(node): string { + function getAppBaseKey(node): string { if (!node || !node.properties) { return ""; } @@ -420,6 +432,33 @@ Singleton { base = appId.toLowerCase(); } } + return base; + } + + function _concurrentStreamsForSameBase(base: string): int { + if (!base) { + return 0; + } + var streams = root.appStreams; + if (!streams) { + return 0; + } + var n = 0; + for (var i = 0; i < streams.length; i++) { + var s = streams[i]; + if (s && getAppBaseKey(s) === base) { + n++; + } + } + return n; + } + + function getAppKey(node): string { + if (!node || !node.properties) { + return ""; + } + var props = node.properties; + var base = root.getAppBaseKey(node); if (!base) { return ""; } @@ -439,6 +478,22 @@ Singleton { return base + "\u001f" + String(node.id); } + function setPanelAppStreamVolume(node, volume: real): void { + _writePanelStickyVolume(node, volume); + var key = getAppKey(node); + if (key) { + setAppStreamVolume(key, volume); + } + } + + function setPanelAppStreamMuted(node, muted: bool): void { + _writePanelStickyMute(node, muted); + var key = getAppKey(node); + if (key) { + setAppStreamMuted(key, muted); + } + } + function setAppStreamVolume(appKey: string, volume: real): void { if (!appKey) { return; @@ -468,12 +523,199 @@ Singleton { return appKey ? (appVolumeOverrides[appKey] || null) : null; } + function _cloneStrMap(m) { + var d = {}; + for (var k in m) { + if (Object.prototype.hasOwnProperty.call(m, k)) { + d[k] = m[k]; + } + } + return d; + } + + function _cloneOverrideMap(ovSrc) { + var out = {}; + for (var ok in ovSrc) { + if (!Object.prototype.hasOwnProperty.call(ovSrc, ok)) { + continue; + } + var inner = ovSrc[ok]; + out[ok] = inner ? { + "volume": inner.volume, + "muted": inner.muted + } : {}; + } + return out; + } + + function _ensureOverrideSlot(o, key) { + if (!o[key]) { + o[key] = {}; + } + return o[key]; + } + + function _panelStickyVolume(key, base) { + if (key && panelAppVolumeByStreamKey[key] !== undefined) { + return panelAppVolumeByStreamKey[key]; + } + if (base && panelAppVolumeByBase[base] !== undefined) { + return panelAppVolumeByBase[base]; + } + return undefined; + } + + function _panelStickyMute(key, base) { + if (key && panelAppMutedByStreamKey[key] !== undefined) { + return panelAppMutedByStreamKey[key]; + } + if (base && panelAppMutedByBase[base] !== undefined) { + return panelAppMutedByBase[base]; + } + return undefined; + } + + function _writePanelStickyVolume(node, volume: real): void { + var base = getAppBaseKey(node); + var key = getAppKey(node); + if (_concurrentStreamsForSameBase(base) > 1 && key) { + var psk = panelAppVolumeByStreamKey; + psk[key] = volume; + panelAppVolumeByStreamKey = psk; + } else if (base) { + var pvb = panelAppVolumeByBase; + pvb[base] = volume; + panelAppVolumeByBase = pvb; + } + } + + function _writePanelStickyMute(node, muted: bool): void { + var base = getAppBaseKey(node); + var key = getAppKey(node); + if (_concurrentStreamsForSameBase(base) > 1 && key) { + var msk = panelAppMutedByStreamKey; + msk[key] = muted; + panelAppMutedByStreamKey = msk; + } else if (base) { + var pmb = panelAppMutedByBase; + pmb[base] = muted; + panelAppMutedByBase = pmb; + } + } + + function _skipPipewireVolumeSyncForNode(node): bool { + var base = getAppBaseKey(node); + var key = getAppKey(node); + if (key && panelAppVolumeByStreamKey[key] !== undefined) { + return true; + } + return !!(base && panelAppVolumeByBase[base] !== undefined && _concurrentStreamsForSameBase(base) <= 1); + } + + function _skipPipewireMuteSyncForNode(node): bool { + var base = getAppBaseKey(node); + var key = getAppKey(node); + if (key && panelAppMutedByStreamKey[key] !== undefined) { + return true; + } + return !!(base && panelAppMutedByBase[base] !== undefined && _concurrentStreamsForSameBase(base) <= 1); + } + + function _migrateBasePanelLocksToPerStreamIfNeeded(): void { + var streams = root.appStreams; + if (!streams || streams.length === 0) { + return; + } + + var bases = {}; + for (var i = 0; i < streams.length; i++) { + var b = getAppBaseKey(streams[i]); + if (b) { + bases[b] = true; + } + } + + var psk = _cloneStrMap(panelAppVolumeByStreamKey); + var msk = _cloneStrMap(panelAppMutedByStreamKey); + var pvb = _cloneStrMap(panelAppVolumeByBase); + var pmb = _cloneStrMap(panelAppMutedByBase); + var oNew = _cloneOverrideMap(appVolumeOverrides); + var changed = false; + + for (var base in bases) { + if (!Object.prototype.hasOwnProperty.call(bases, base)) { + continue; + } + if (_concurrentStreamsForSameBase(base) <= 1) { + continue; + } + var volB = pvb[base]; + var muteB = pmb[base]; + if (volB === undefined && muteB === undefined) { + continue; + } + + for (var j = 0; j < streams.length; j++) { + var s = streams[j]; + if (!s || getAppBaseKey(s) !== base) { + continue; + } + var key = getAppKey(s); + if (!key) { + continue; + } + if (volB !== undefined && psk[key] === undefined) { + psk[key] = volB; + _ensureOverrideSlot(oNew, key).volume = volB; + changed = true; + } + if (muteB !== undefined && msk[key] === undefined) { + msk[key] = muteB; + _ensureOverrideSlot(oNew, key).muted = muteB; + changed = true; + } + } + if (volB !== undefined) { + delete pvb[base]; + changed = true; + } + if (muteB !== undefined) { + delete pmb[base]; + changed = true; + } + } + + if (!changed) { + return; + } + panelAppVolumeByStreamKey = psk; + panelAppMutedByStreamKey = msk; + panelAppVolumeByBase = pvb; + panelAppMutedByBase = pmb; + appVolumeOverrides = oNew; + } + + function _seedNewStreamOverride(key, base, audio) { + var seeded = appVolumeOverrides; + if (!seeded[key]) { + seeded[key] = {}; + } + var pv = _panelStickyVolume(key, base); + var pm = _panelStickyMute(key, base); + seeded[key].volume = (pv !== undefined) ? pv : audio.volume; + seeded[key].muted = (pm !== undefined) ? pm : audio.muted; + appVolumeOverrides = seeded; + return seeded[key]; + } + function _applyAppOverrides(): void { var streams = root.appStreams; if (!streams) { return; } + root._migrateBasePanelLocksToPerStreamIfNeeded(); + var prevKnown = root._knownAppStreamIds; var currentIds = {}; _isApplyingAppOverride = true; @@ -485,29 +727,26 @@ Singleton { currentIds[s.id] = true; var key = getAppKey(s); + var base = getAppBaseKey(s); var ov = key ? appVolumeOverrides[key] : null; - // New stream node (reload, app restart, etc.): adopt PipeWire state into - // overrides so we do not force an outdated Noctalia-only value. if (key && s.audio && !prevKnown[s.id]) { - var seeded = appVolumeOverrides; - if (!seeded[key]) { - seeded[key] = {}; - } - seeded[key].volume = s.audio.volume; - seeded[key].muted = s.audio.muted; - appVolumeOverrides = seeded; - ov = seeded[key]; + ov = _seedNewStreamOverride(key, base, s.audio); } - if (!ov || !s.audio) { + if (!s.audio) { continue; } - if (ov.volume !== undefined && Math.abs(s.audio.volume - ov.volume) > root.epsilon) { - s.audio.volume = ov.volume; + + var panelVol = _panelStickyVolume(key, base); + var panelMute = _panelStickyMute(key, base); + var targetVol = (panelVol !== undefined) ? panelVol : (ov && ov.volume !== undefined ? ov.volume : undefined); + var targetMuted = (panelMute !== undefined) ? panelMute : (ov && ov.muted !== undefined ? ov.muted : undefined); + if (targetVol !== undefined && Math.abs(s.audio.volume - targetVol) > root.epsilon) { + s.audio.volume = targetVol; } - if (ov.muted !== undefined && s.audio.muted !== ov.muted) { - s.audio.muted = ov.muted; + if (targetMuted !== undefined && s.audio.muted !== targetMuted) { + s.audio.muted = targetMuted; } } _knownAppStreamIds = currentIds;