fix(audio): sticky app-stream volume + subtitle line in panel

This commit is contained in:
Lysec
2026-04-03 12:00:33 +02:00
parent 359f4afa73
commit 759454d2d5
2 changed files with 309 additions and 24 deletions
+257 -18
View File
@@ -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;