aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/MediaPill.qml
blob: fa4a6ba7deda1a33ce85e7e8e4c2add297d22a6a (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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// 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.

import Quickshell.Widgets
import QtQuick
import ".."
import "../modules/sound"

// Album art and a scrolling `artist · album · title`, for the player the
// sound module's Player picks. Hovering swaps the text for a progress bar,
// which seeks on click when the player allows it.
Capsule {
    id: pill

    readonly property var player: Player.current
    readonly property string line:
        [Player.artist, player?.trackAlbum ?? "", Player.title].filter(s => s).join("  ·  ")

    readonly property bool canSeek:
        !!player && player.canSeek && player.positionSupported && player.lengthSupported && player.length > 0

    restWidth: art.width + textBox.width + 34

    // MPRIS does not push position; positionChanged() makes it re-read.
    Timer {
        interval: 1000
        repeat: true
        running: pill.hovered && Player.playing
        triggeredOnStart: true
        onTriggered: pill.player?.positionChanged()
    }

    ClippingRectangle {
        id: art
        x: 8
        y: (pill.restHeight - height) / 2
        width: 22; height: 22; radius: 4
        color: Qt.alpha(Theme.surface, 0.8)

        Image {
            anchors.fill: parent
            source: Player.artUrl
            cache: false
            asynchronous: true
            fillMode: Image.PreserveAspectCrop
        }
    }

    // Marquee: two copies side by side, scrolled by one copy's width, so the
    // loop point is invisible. Only scrolls when the line does not fit.
    Item {
        id: textBox
        x: art.x + art.width + 10
        y: (pill.restHeight - height) / 2
        // At least 100, so a short title still leaves room for the bar.
        width: Math.max(100, Math.min(220, first.implicitWidth))
        height: first.implicitHeight
        clip: true
        opacity: pill.hovered ? 0 : 1
        Behavior on opacity { NumberAnimation { duration: 120 } }

        readonly property bool scroll: first.implicitWidth > 220

        Row {
            id: marquee
            spacing: 40
            Text {
                id: first
                text: pill.line
                font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
                color: Theme.text
            }
            Text {
                visible: textBox.scroll
                text: pill.line
                font: first.font
                color: first.color
            }
        }

        NumberAnimation {
            id: scroller
            target: marquee
            property: "x"
            from: 0
            to: -(first.implicitWidth + marquee.spacing)
            duration: (first.implicitWidth + marquee.spacing) * 30
            loops: Animation.Infinite
            running: textBox.scroll && pill.visible
            onRunningChanged: if (!running) marquee.x = 0
        }
        Connections {
            target: first
            function onTextChanged() { marquee.x = 0; if (scroller.running) scroller.restart(); }
        }
    }

    // Wheel up for the previous track, down for the next, one per notch
    // (120) so a touchpad does not skip several. Takes no buttons, so the
    // seek bar's clicks still reach it.
    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.NoButton
        property real acc: 0
        onWheel: wheel => {
            acc += wheel.angleDelta.y;
            if (Math.abs(acc) >= 120) {
                const p = pill.player;
                if (acc > 0 && p?.canGoPrevious) p.previous();
                else if (acc < 0 && p?.canGoNext) p.next();
                acc = 0;
            }
            wheel.accepted = true;
        }
    }

    // Progress bar, over the text while hovered.
    Item {
        x: textBox.x
        width: textBox.width
        height: 12
        anchors.verticalCenter: parent.verticalCenter
        opacity: 1 - textBox.opacity
        visible: opacity > 0

        Rectangle {
            anchors.verticalCenter: parent.verticalCenter
            width: parent.width
            height: 4
            radius: 2
            color: Qt.alpha(Theme.text, 0.15)

            Rectangle {
                width: pill.player && pill.player.length > 0
                    ? parent.width * Math.min(1, pill.player.position / pill.player.length) : 0
                height: parent.height
                radius: 2
                color: Theme.accent
            }
        }

        MouseArea {
            anchors.fill: parent
            enabled: pill.canSeek
            cursorShape: Qt.PointingHandCursor
            onClicked: mouse => pill.player.position = pill.player.length * mouse.x / width
        }
    }
}