pragma Singleton import QtQuick import Quickshell import Quickshell.Services.Mpris import qs.Commons Singleton { id: root function formatTime(seconds) { if (isNaN(seconds) || seconds < 0) return "0:00"; var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); var pad = function (n) { return (n < 10) ? ("0" + n) : n; }; if (h > 0) { return h + ":" + pad(m) + ":" + pad(s); } else { return m + ":" + pad(s); } } property var currentPlayer: null property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : "" property real currentPosition: 0 property bool isSeeking: false property int selectedPlayerIndex: 0 property bool isPlaying: currentPlayer ? (currentPlayer.playbackState === MprisPlaybackState.Playing || currentPlayer.isPlaying) : false property string trackTitle: currentPlayer ? (currentPlayer.trackTitle !== undefined ? currentPlayer.trackTitle.replace(/(\r\n|\n|\r)/g, "") : "") : "" property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "") : "" property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "") : "" property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" property real trackLength: currentPlayer ? ((currentPlayer.length < infiniteTrackLength) ? currentPlayer.length : 0) : 0 property bool canPlay: currentPlayer ? currentPlayer.canPlay : false property bool canPause: currentPlayer ? currentPlayer.canPause : false property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false property bool canSeek: currentPlayer ? currentPlayer.canSeek : false property string positionString: formatTime(currentPosition) property string lengthString: formatTime(trackLength) property real infiniteTrackLength: 922337203685 Component.onCompleted: { updateCurrentPlayer(); } function getAvailablePlayers() { if (!Mpris.players || !Mpris.players.values) { return []; } let allPlayers = Mpris.players.values; let finalPlayers = []; const genericBrowsers = ["firefox", "chromium", "chrome"]; const blacklist = (Settings.data.audio && Settings.data.audio.mprisBlacklist) ? Settings.data.audio.mprisBlacklist : []; // Separate players into specific and generic lists let specificPlayers = []; let genericPlayers = []; for (var i = 0; i < allPlayers.length; i++) { if (!allPlayers[i]) continue; const identity = String(allPlayers[i].identity || "").toLowerCase(); const dbusName = String(allPlayers[i].dbusName || "").toLowerCase(); const match = blacklist.find(b => { const s = String(b || "").toLowerCase(); return s && (identity.includes(s) || dbusName.includes(s)); }); if (match) continue; if (genericBrowsers.some(b => identity.includes(b))) { genericPlayers.push(allPlayers[i]); } else { specificPlayers.push(allPlayers[i]); } } let matchedGenericIndices = {}; // For each specific player, try to find and pair it with a generic partner for (var i = 0; i < specificPlayers.length; i++) { let specificPlayer = specificPlayers[i]; let title1 = String(specificPlayer.trackTitle || "").trim(); let wasMatched = false; if (title1) { for (var j = 0; j < genericPlayers.length; j++) { if (matchedGenericIndices[j]) continue; let genericPlayer = genericPlayers[j]; let title2 = String(genericPlayer.trackTitle || "").trim(); if (title2 && (title1.includes(title2) || title2.includes(title1))) { let dataPlayer = genericPlayer; let identityPlayer = specificPlayer; let scoreSpecific = (specificPlayer.trackArtUrl ? 1 : 0); let scoreGeneric = (genericPlayer.trackArtUrl ? 1 : 0); if (scoreSpecific > scoreGeneric) { dataPlayer = specificPlayer; } let virtualPlayer = { "identity": identityPlayer.identity, "desktopEntry": identityPlayer.desktopEntry, "trackTitle": dataPlayer.trackTitle, "trackArtist": dataPlayer.trackArtist, "trackAlbum": dataPlayer.trackAlbum, "trackArtUrl": dataPlayer.trackArtUrl, "length": dataPlayer.length || 0, "position": dataPlayer.position || 0, "playbackState": dataPlayer.playbackState, "isPlaying": dataPlayer.isPlaying || false, "canPlay": dataPlayer.canPlay || false, "canPause": dataPlayer.canPause || false, "canGoNext": dataPlayer.canGoNext || false, "canGoPrevious": dataPlayer.canGoPrevious || false, "canSeek": dataPlayer.canSeek || false, "canControl": dataPlayer.canControl || false, "_stateSource": dataPlayer, "_controlTarget": identityPlayer }; finalPlayers.push(virtualPlayer); matchedGenericIndices[j] = true; wasMatched = true; break; } } } if (!wasMatched) { finalPlayers.push(specificPlayer); } } // Add any generic players that were not matched for (var i = 0; i < genericPlayers.length; i++) { if (!matchedGenericIndices[i]) { finalPlayers.push(genericPlayers[i]); } } // Filter for controllable players let controllablePlayers = []; for (var i = 0; i < finalPlayers.length; i++) { let player = finalPlayers[i]; if (player && player.canPlay) { controllablePlayers.push(player); } } return controllablePlayers; } function findActivePlayer() { let availablePlayers = getAvailablePlayers(); if (availablePlayers.length === 0) { //Logger.i("Media", "No active player found") return null; } // Prioritize the actively playing player --- for (var i = 0; i < availablePlayers.length; i++) { if (availablePlayers[i] && availablePlayers[i].playbackState === MprisPlaybackState.Playing) { Logger.d("Media", "Found actively playing player: " + availablePlayers[i].identity); selectedPlayerIndex = i; return availablePlayers[i]; } } // fallback if nothing is playing) const preferred = (Settings.data.audio.preferredPlayer || ""); if (preferred !== "") { for (var i = 0; i < availablePlayers.length; i++) { const p = availablePlayers[i]; const identity = String(p.identity || "").toLowerCase(); const pref = preferred.toLowerCase(); if (identity.includes(pref)) { selectedPlayerIndex = i; return p; } } } if (selectedPlayerIndex < availablePlayers.length) { return availablePlayers[selectedPlayerIndex]; } else { selectedPlayerIndex = 0; return availablePlayers[0]; } } property bool autoSwitchingPaused: false function switchToPlayer(index) { let availablePlayers = getAvailablePlayers(); if (index >= 0 && index < availablePlayers.length) { let newPlayer = availablePlayers[index]; if (newPlayer !== currentPlayer) { currentPlayer = newPlayer; selectedPlayerIndex = index; currentPosition = currentPlayer ? currentPlayer.position : 0; Logger.d("Media", "Manually switched to player " + currentPlayer.identity); } } } // Switch to the most recently active player function updateCurrentPlayer() { let newPlayer = findActivePlayer(); if (newPlayer !== currentPlayer) { currentPlayer = newPlayer; currentPosition = currentPlayer ? currentPlayer.position : 0; Logger.d("Media", "Switching player"); } } function playPause() { if (currentPlayer) { let stateSource = currentPlayer._stateSource || currentPlayer; let controlTarget = currentPlayer._controlTarget || currentPlayer; if (stateSource.playbackState === MprisPlaybackState.Playing) { controlTarget.pause(); } else { controlTarget.play(); } } } function play() { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canPlay) { target.play(); } } function stop() { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target) { target.stop(); } } function pause() { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canPause) { target.pause(); } } function next() { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canGoNext) { target.next(); } } function previous() { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canGoPrevious) { target.previous(); } } function seek(position) { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canSeek) { target.position = position; currentPosition = position; } } function seekRelative(offset) { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canSeek && target.length > 0) { let seekPosition = target.position + offset; target.position = seekPosition; currentPosition = seekPosition; } } // Seek to position based on ratio (0.0 to 1.0) function seekByRatio(ratio) { let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; if (target && target.canSeek && target.length > 0) { let seekPosition = ratio * target.length; target.position = seekPosition; currentPosition = seekPosition; } } // Update progress bar every second while playing Timer { id: positionTimer interval: 1000 running: currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.length > 0 && currentPlayer.playbackState === MprisPlaybackState.Playing repeat: true onTriggered: { if (currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.playbackState === MprisPlaybackState.Playing) { currentPosition = currentPlayer.position; } else { running = false; } } } // Avoid overwriting currentPosition while seeking due to backend position changes Connections { target: currentPlayer function onPositionChanged() { if (!root.isSeeking && currentPlayer) { currentPosition = currentPlayer.position; } } function onPlaybackStateChanged() { if (!root.isSeeking && currentPlayer) { currentPosition = currentPlayer.position; } } } // Reset position when switching to inactive player onCurrentPlayerChanged: { if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) { currentPosition = 0; } } Timer { id: playerStateMonitor interval: 2000 // Check every 2 seconds repeat: true running: true onTriggered: { //Logger.d("MediaService", "playerStateMonitor triggered. autoSwitchingPaused: " + root.autoSwitchingPaused) if (autoSwitchingPaused) return; // Only update if we don't have a playing player or if current player is paused if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) { updateCurrentPlayer(); } } } // Update current player when available players change Connections { target: Mpris.players function onValuesChanged() { Logger.d("Media", "Players changed"); updateCurrentPlayer(); } } }