This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
282 lines
9.8 KiB
QML
282 lines
9.8 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
// From https://git.outfoxxed.me/outfoxxed/nixnew
|
|
// It does not have a license, but the author is okay with redistribution.
|
|
|
|
import QtQml.Models
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Mpris
|
|
import qs.modules.common
|
|
|
|
/**
|
|
* A service that provides easy access to the active Mpris player.
|
|
*/
|
|
Singleton {
|
|
id: root;
|
|
property list<MprisPlayer> players: Mpris.players.values.filter(player => isRealPlayer(player));
|
|
property MprisPlayer trackedPlayer: null;
|
|
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null;
|
|
signal trackChanged(reverse: bool);
|
|
|
|
property bool __reverse: false;
|
|
|
|
property var activeTrack;
|
|
|
|
readonly property bool hasActivePlasmaIntegration: Mpris.players.values.some(
|
|
p => p.dbusName?.startsWith('org.mpris.MediaPlayer2.plasma-browser-integration')
|
|
)
|
|
function isRealPlayer(player) {
|
|
if (!Config.options.media.filterDuplicatePlayers) {
|
|
return true;
|
|
}
|
|
return (
|
|
// Remove native browser buses only if plasma-browser-integration is actually active on D-Bus
|
|
!(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) &&
|
|
// playerctld just copies other buses and we don't need duplicates
|
|
!player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') &&
|
|
// Non-instance mpd bus
|
|
!(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd')));
|
|
}
|
|
|
|
// Last non-empty trackArtUrl seen per player (keyed by dbusName). Some
|
|
// MPRIS players (Firefox via firefox-mpris, Spotify) emit transient empty
|
|
// trackArtUrl between/during tracks. Consumers like PlayerControl that get
|
|
// recreated on panel open/close need a stable fallback so the cover
|
|
// doesn't vanish whenever they happen to mount during an empty window.
|
|
// dbusName is the right key here: MprisPlayer.uniqueId in Quickshell is a
|
|
// per-track identifier (it increments when the track changes), not a
|
|
// per-player one, so keying on it cross-contaminates between players that
|
|
// happen to share a uniqueId value.
|
|
property var stableArtUrlByPlayer: ({})
|
|
|
|
function _captureArtUrl(player) {
|
|
if (player?.trackArtUrl?.length > 0 && !!player?.dbusName) {
|
|
const map = Object.assign({}, root.stableArtUrlByPlayer);
|
|
map[player.dbusName] = player.trackArtUrl;
|
|
root.stableArtUrlByPlayer = map;
|
|
}
|
|
}
|
|
|
|
// Best (largest) downloaded cover file seen per player for the current
|
|
// track, keyed by dbusName. Persists across PlayerControl lifecycles so
|
|
// reopening the panel doesn't downgrade to a thumbnail when Firefox
|
|
// happens to be sitting on its low-res variant. Entry shape:
|
|
// { trackKey: string, artFilePath: string, artBytes: number }
|
|
property var bestArtByPlayer: ({})
|
|
|
|
function rememberBestArt(player, trackKey, artFilePath, artBytes) {
|
|
const id = player?.dbusName;
|
|
if (!id || artBytes <= 0 || !artFilePath) return;
|
|
const existing = root.bestArtByPlayer[id];
|
|
// Same track and existing is already >= new size: nothing to do.
|
|
if (existing && existing.trackKey === trackKey && existing.artBytes >= artBytes) return;
|
|
const map = Object.assign({}, root.bestArtByPlayer);
|
|
map[id] = { trackKey: trackKey, artFilePath: artFilePath, artBytes: artBytes };
|
|
root.bestArtByPlayer = map;
|
|
}
|
|
|
|
function getBestArt(player, trackKey) {
|
|
const id = player?.dbusName;
|
|
if (!id) return null;
|
|
const entry = root.bestArtByPlayer[id];
|
|
if (!entry || entry.trackKey !== trackKey) return null;
|
|
return entry;
|
|
}
|
|
|
|
function _trackKeyOf(player) {
|
|
// title|artist (album omitted on purpose). Some players (Firefox via
|
|
// firefox-mpris) emit metadata progressively: first trackArtUrl +
|
|
// title + artist with album="", then a moment later update album.
|
|
// If album were part of the key, the high-res variant emitted with
|
|
// the partial metadata and the low-res variant emitted with the
|
|
// completed metadata would look like different tracks to the
|
|
// "never-downgrade" guard.
|
|
return `${player?.trackTitle ?? ""}|${player?.trackArtist ?? ""}`;
|
|
}
|
|
|
|
// Per-player worker. Holds a Process that downloads each new trackArtUrl
|
|
// the player emits, regardless of whether the media controls panel is
|
|
// open. This is what makes the cover stay sharp across panel
|
|
// close/reopen and across auto-advance while the panel is closed —
|
|
// PlayerControl is no longer the only thing watching for art emissions.
|
|
component PlayerWorker: QtObject {
|
|
id: worker
|
|
required property MprisPlayer player
|
|
|
|
function _fetchArt() {
|
|
const url = worker.player?.trackArtUrl;
|
|
if (!url || url.length === 0) return;
|
|
artDownloader.trackKey = root._trackKeyOf(worker.player);
|
|
artDownloader.targetFile = url;
|
|
artDownloader.artFilePath = `${Directories.coverArt}/${Qt.md5(url)}`;
|
|
artDownloader.running = true;
|
|
}
|
|
|
|
property Process artDownloader: Process {
|
|
property string trackKey
|
|
property string targetFile
|
|
property string artFilePath
|
|
property int sizeBytes: 0
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
const n = parseInt(data.trim());
|
|
if (!isNaN(n)) artDownloader.sizeBytes = n;
|
|
}
|
|
}
|
|
command: ["bash", "-c", `[ -f ${artFilePath} ] || curl -4 -sSL '${targetFile}' -o '${artFilePath}'; stat -c %s '${artFilePath}' 2>/dev/null`]
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (exitCode !== 0 || sizeBytes <= 0 || artFilePath.length === 0) return;
|
|
root.rememberBestArt(worker.player, trackKey, artFilePath, sizeBytes);
|
|
}
|
|
}
|
|
|
|
property Connections _conn: Connections {
|
|
target: worker.player
|
|
function onPlaybackStateChanged() {
|
|
if (root.trackedPlayer !== worker.player) root.trackedPlayer = worker.player;
|
|
}
|
|
function onTrackArtUrlChanged() {
|
|
root._captureArtUrl(worker.player);
|
|
worker._fetchArt();
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
if (root.trackedPlayer == null || worker.player.isPlaying) {
|
|
root.trackedPlayer = worker.player;
|
|
}
|
|
root._captureArtUrl(worker.player);
|
|
worker._fetchArt();
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
|
|
for (const p of Mpris.players.values) {
|
|
if (p.playbackState.isPlaying) {
|
|
root.trackedPlayer = p;
|
|
break;
|
|
}
|
|
}
|
|
if (root.trackedPlayer == null && Mpris.players.values.length != 0) {
|
|
root.trackedPlayer = Mpris.players.values[0];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Instantiator {
|
|
model: Mpris.players
|
|
delegate: PlayerWorker {
|
|
required property MprisPlayer modelData
|
|
player: modelData
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: activePlayer
|
|
|
|
function onPostTrackChanged() {
|
|
root.updateTrack();
|
|
}
|
|
|
|
function onTrackArtUrlChanged() {
|
|
// console.log("arturl:", activePlayer.trackArtUrl)
|
|
// root.updateTrack();
|
|
if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) {
|
|
// cantata likes to send cover updates *BEFORE* updating the track info.
|
|
// as such, art url changes shouldn't be able to break the reverse animation
|
|
const r = root.__reverse;
|
|
root.updateTrack();
|
|
root.__reverse = r;
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
onActivePlayerChanged: this.updateTrack();
|
|
|
|
function updateTrack() {
|
|
//console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`)
|
|
this.activeTrack = {
|
|
uniqueId: this.activePlayer?.uniqueId ?? 0,
|
|
artUrl: this.activePlayer?.trackArtUrl ?? "",
|
|
title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"),
|
|
artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"),
|
|
album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"),
|
|
};
|
|
|
|
this.trackChanged(__reverse);
|
|
this.__reverse = false;
|
|
}
|
|
|
|
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying;
|
|
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false;
|
|
function togglePlaying() {
|
|
if (this.canTogglePlaying) this.activePlayer.togglePlaying();
|
|
}
|
|
|
|
property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false;
|
|
function previous() {
|
|
if (this.canGoPrevious) {
|
|
this.__reverse = true;
|
|
this.activePlayer.previous();
|
|
}
|
|
}
|
|
|
|
property bool canGoNext: this.activePlayer?.canGoNext ?? false;
|
|
function next() {
|
|
if (this.canGoNext) {
|
|
this.__reverse = false;
|
|
this.activePlayer.next();
|
|
}
|
|
}
|
|
|
|
property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl;
|
|
|
|
property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl;
|
|
property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None;
|
|
function setLoopState(loopState: var) {
|
|
if (this.loopSupported) {
|
|
this.activePlayer.loopState = loopState;
|
|
}
|
|
}
|
|
|
|
property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl;
|
|
property bool hasShuffle: this.activePlayer?.shuffle ?? false;
|
|
function setShuffle(shuffle: bool) {
|
|
if (this.shuffleSupported) {
|
|
this.activePlayer.shuffle = shuffle;
|
|
}
|
|
}
|
|
|
|
function setActivePlayer(player: MprisPlayer) {
|
|
const targetPlayer = player ?? Mpris.players[0];
|
|
console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`)
|
|
|
|
if (targetPlayer && this.activePlayer) {
|
|
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer);
|
|
} else {
|
|
// always animate forward if going to null
|
|
this.__reverse = false;
|
|
}
|
|
|
|
this.trackedPlayer = targetPlayer;
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "mpris"
|
|
|
|
function pauseAll(): void {
|
|
for (const player of Mpris.players.values) {
|
|
if (player.canPause) player.pause();
|
|
}
|
|
}
|
|
|
|
function playPause(): void { root.togglePlaying(); }
|
|
function previous(): void { root.previous(); }
|
|
function next(): void { root.next(); }
|
|
}
|
|
}
|