aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sound/Player.qml
blob: 7522d8090ec855e84686199e7fd410436ba29653 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// 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; }
    }
}