// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import Quickshell.Io import Quickshell.Services.Mpris import QtQuick // Which MPRIS player the OSD should describe. Singleton { id: root // Two things proxy a real player and republish it under their own name, so // one track can appear on three buses at once: a playing Navidrome tab // showed up as firefox.instance_*, as plasma-browser-integration and as // playerctld. Both proxies are skipped and the OSD talks to the browser's // own entry, which is safe because none of the three exists until playback // starts, and the browser's entry appeared whenever the plasma one did. An // open but silent tab publishes nothing, so there is no row to lose. The // plasma name is browser-only: Feishin on the same server publishes just // its own entry and playerctld, so a native player loses nothing here. readonly property var proxyNames: [".playerctld", ".plasma-browser-integration"] readonly property var real: Mpris.players.values.filter(p => !root.proxyNames.some(n => p.dbusName.endsWith(n)) // A page can hold a silent MPRIS session that is // not a track: WhatsApp Web sits paused forever with // the tab title as `xesam:title` and nothing else. // Paused with no artist and no length is that shape; // a real paused track keeps its length (and usually // its artist), so it survives. && !(p.playbackState === MprisPlaybackState.Paused && !p.lengthSupported && !p.trackArtist)) // Prefer something actually playing; otherwise keep the last one seen, so // pausing does not make the track row vanish mid-look. readonly property var current: real.find(p => p.playbackState === MprisPlaybackState.Playing) ?? real.find(p => p.playbackState === MprisPlaybackState.Paused) ?? real[0] ?? null readonly property bool active: current !== null readonly property bool playing: current?.playbackState === MprisPlaybackState.Playing readonly property string title: current?.trackTitle ?? "" readonly property string artist: current?.trackArtist ?? "" // Players that extract embedded art write it to a temp file they reuse // per track, so the path can repeat while the image behind it changes. // The cache buster makes Image reload instead of showing the last cover. readonly property string artUrl: (current?.trackArtUrl ?? "") !== "" ? current.trackArtUrl + "#" + encodeURIComponent(title) : (videoUrl !== "" && thumbFor === videoUrl) ? "file://" + thumbPath + "#" + encodeURIComponent(videoUrl) : "" // Haruna clears mpris:artUrl for every video on purpose (so the task // manager shows the live window), so a local video with no cover gets a // frame grabbed by ffmpeg instead. One file, overwritten per video; the // URL fragment above makes Image reload it. readonly property string videoUrl: { const url = String(current?.metadata?.["xesam:url"] ?? ""); return (current?.trackArtUrl ?? "") === "" && url.startsWith("file://") && /\.(mp4|m4v|mkv|webm|avi|mov|wmv|flv|mpe?g|ts|ogv)$/i.test(url) ? url : ""; } readonly property string thumbPath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/qs-video-thumb.jpg" property string thumbFor: "" onVideoUrlChanged: grab() Component.onCompleted: grab() function grab() { if (videoUrl === "") return; thumb.running = false; thumb.url = videoUrl; thumb.command = ["ffmpeg", "-y", "-v", "error", "-i", decodeURIComponent(videoUrl.slice(7)), "-vf", "thumbnail,scale=480:-2", "-frames:v", "1", thumbPath]; thumb.running = true; } Process { id: thumb property string url: "" onExited: code => { if (code === 0) root.thumbFor = url; } } }