ExpandedRepresentation.qml 19.9 KB
Newer Older
1
2
/***************************************************************************
 *   Copyright 2013 Sebastian Kügler <sebas@kde.org>                       *
3
 *   Copyright 2014, 2016 Kai Uwe Broulik <kde@privat.broulik.de>          *
4
5
 *   Copyright 2020 Carson Black <uhhadd@gmail.com>                        *
 *   Copyright 2020 Ismael Asensio <isma.af@gmail.com>                     *
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU Library General Public License as       *
 *   published by the Free Software Foundation; either version 2 of the    *
 *   License, or (at your option) any later version.                       *
 *                                                                         *
 *   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 Library General Public License for more details.                  *
 *                                                                         *
 *   You should have received a copy of the GNU Library General Public     *
 *   License along with this program; if not, write to the                 *
 *   Free Software Foundation, Inc.,                                       *
 *   51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA .        *
 ***************************************************************************/

23
import QtQuick 2.8
24
25
import QtQuick.Layouts 1.1
import org.kde.plasma.core 2.0 as PlasmaCore
Marco Martin's avatar
Marco Martin committed
26
import org.kde.plasma.components 3.0 as PlasmaComponents3
27
import org.kde.plasma.extras 2.0 as PlasmaExtras
28
import org.kde.kcoreaddons 1.0 as KCoreAddons
29
30
import org.kde.kirigami 2.4 as Kirigami
import QtGraphicalEffects 1.0
31

32
Item {
33
34
    id: expandedRepresentation

35
36
    Layout.minimumWidth: units.gridUnit * 14
    Layout.minimumHeight: units.gridUnit * 14
37
38
39
    Layout.preferredWidth: Layout.minimumWidth * 1.5
    Layout.preferredHeight: Layout.minimumHeight * 1.5

40
    readonly property int controlSize: units.iconSizes.large
41

42
    property double position: mpris2Source.currentData.Position || 0
43
    readonly property real rate: mpris2Source.currentData.Rate || 1
44
    readonly property double length: currentMetadata ? currentMetadata["mpris:length"] || 0 : 0
45
    readonly property bool canSeek: mpris2Source.currentData.CanSeek || false
46
    readonly property bool softwareRendering: GraphicsInfo.api === GraphicsInfo.Software
47

48
49
50
    // only show hours (the default for KFormat) when track is actually longer than an hour
    readonly property int durationFormattingOptions: length >= 60*60*1000*1000 ? 0 : KCoreAddons.FormatTypes.FoldHours

Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
51
    property bool disablePositionUpdate: false
52
    property bool keyPressed: false
53

54
55
56
57
58
59
    function retrievePosition() {
        var service = mpris2Source.serviceForSource(mpris2Source.current);
        var operation = service.operationDescription("GetPosition");
        service.startOperationCall(operation);
    }

60
61
62
63
64
65
    Connections {
        target: plasmoid
        onExpandedChanged: {
            if (plasmoid.expanded) {
                retrievePosition();
            }
66
67
68
69
        }
    }

    onPositionChanged: {
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
70
        // we don't want to interrupt the user dragging the slider
71
        if (!seekSlider.pressed && !keyPressed) {
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
72
73
74
75
            // we also don't want passive position updates
            disablePositionUpdate = true
            seekSlider.value = position
            disablePositionUpdate = false
76
77
78
        }
    }

79
80
81
82
83
84
85
    onLengthChanged: {
        disablePositionUpdate = true
        // When reducing maximumValue, value is clamped to it, however
        // when increasing it again it gets its old value back.
        // To keep us from seeking to the end of the track when moving
        // to a new track, we'll reset the value to zero and ask for the position again
        seekSlider.value = 0
Marco Martin's avatar
Marco Martin committed
86
        seekSlider.to = length
87
88
89
90
        retrievePosition()
        disablePositionUpdate = false
    }

91
92
93
94
95
96
97
98
99
100
    Keys.onPressed: keyPressed = true

    Keys.onReleased: {
        keyPressed = false

        if (!event.modifiers) {
            event.accepted = true

            if (event.key === Qt.Key_Space || event.key === Qt.Key_K) {
                // K is YouTube's key for "play/pause" :)
101
                root.togglePlaying()
102
            } else if (event.key === Qt.Key_P) {
103
                root.action_previous()
104
            } else if (event.key === Qt.Key_N) {
105
                root.action_next()
106
            } else if (event.key === Qt.Key_S) {
107
                root.action_stop()
108
109
110
            } else if (event.key === Qt.Key_Left || event.key === Qt.Key_J) { // TODO ltr languages
                // seek back 5s
                seekSlider.value = Math.max(0, seekSlider.value - 5000000) // microseconds
111
                seekSlider.moved();
112
113
            } else if (event.key === Qt.Key_Right || event.key === Qt.Key_L) {
                // seek forward 5s
Marco Martin's avatar
Marco Martin committed
114
                seekSlider.value = Math.min(seekSlider.to, seekSlider.value + 5000000)
115
                seekSlider.moved();
116
117
            } else if (event.key === Qt.Key_Home) {
                seekSlider.value = 0
118
                seekSlider.moved();
119
            } else if (event.key === Qt.Key_End) {
Marco Martin's avatar
Marco Martin committed
120
                seekSlider.value = seekSlider.to
121
                seekSlider.moved();
122
123
            } else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
                // jump to percentage, ie. 0 = beginnign, 1 = 10% of total length etc
Marco Martin's avatar
Marco Martin committed
124
                seekSlider.value = seekSlider.to * (event.key - Qt.Key_0) / 10
125
                seekSlider.moved();
126
127
128
129
130
131
            } else {
                event.accepted = false
            }
        }
    }

132
133
134
    ColumnLayout { // Main Column Layout
        id: mainCol
        anchors.fill: parent
135

136
137
138
        Item { // Album Art Background + Details
            Layout.fillWidth: true
            Layout.fillHeight: true
139

140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
            Image {
                id: backgroundImage

                source: root.albumArt
                sourceSize.width: 512 /*
                                       * Setting a sourceSize.width here
                                       * prevents flickering when resizing the
                                       * plasmoid on a desktop.
                                       */

                anchors.fill: parent
                anchors.margins: -units.smallSpacing*2
                fillMode: Image.PreserveAspectCrop

                asynchronous: true
                visible: !!root.track && status === Image.Ready && !softwareRendering
156

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
                layer.enabled: !softwareRendering
                layer.effect: HueSaturation {
                    cached: true

                    lightness: -0.5
                    saturation: 0.9

                    layer.enabled: true
                    layer.effect: GaussianBlur {
                        cached: true

                        radius: 256
                        deviation: 12
                        samples: 129

                        transparentBorder: false
                    }
174
175
                }
            }
176
177
            RowLayout { // Album Art + Details
                id: albumRow
178

179
180
181
182
183
184
185
                anchors {
                    fill: parent
                    leftMargin: units.largeSpacing
                    rightMargin: units.largeSpacing
                }

                spacing: units.largeSpacing
186

187
188
189
190
                Item {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    Layout.preferredWidth: 50
191

192
193
                    Image { // Album Art
                        id: albumArt
194

195
                        anchors.fill: parent
196

197
                        visible: !!root.track && status === Image.Ready
198

199
                        asynchronous: true
200

201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
                        horizontalAlignment: Image.AlignRight
                        verticalAlignment: Image.AlignVCenter
                        fillMode: Image.PreserveAspectFit

                        source: root.albumArt
                    }

                    PlasmaCore.IconItem { // Fallback
                        visible: !albumArt.visible
                        source: {
                            if (mpris2Source.currentData["Desktop Icon Name"])
                                return mpris2Source.currentData["Desktop Icon Name"]
                            return "media-album-cover"
                        }

                        anchors {
                            fill: parent
                            margins: units.largeSpacing*2
                        }
                    }
                }

                ColumnLayout { // Details Column
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    Layout.preferredWidth: 50
                    Layout.alignment: !(albumArt.visible || !!mpris2Source.currentData["Desktop Icon Name"]) ? Qt.AlignHCenter : 0

                    /*
                     * We use Kirigami.Heading instead of PlasmaExtras.Heading
                     * to prevent a binding loop caused by the PC2 Label component
                     * used by PlasmaExtras.Heading
                     */
                    Kirigami.Heading { // Song Title
                        id: songTitle
                        level: 1

                        color: (softwareRendering || !albumArt.visible) ? PlasmaCore.ColorScope.textColor : "white"

                        textFormat: Text.PlainText
                        wrapMode: Text.Wrap
                        fontSizeMode: Text.VerticalFit
243
                        elide: Text.ElideRight
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259

                        text: root.track || i18n("No media playing")

                        Layout.fillWidth: true
                        Layout.maximumHeight: units.gridUnit*5
                    }
                    Kirigami.Heading { // Song Artist
                        id: songArtist
                        visible: root.track && root.artist
                        level: 2

                        color: (softwareRendering || !albumArt.visible) ? PlasmaCore.ColorScope.textColor : "white"

                        textFormat: Text.PlainText
                        wrapMode: Text.Wrap
                        fontSizeMode: Text.VerticalFit
260
                        elide: Text.ElideRight
261
262
263
264
265
266
267
268
269
270
271
272
273
274

                        text: root.artist
                        Layout.fillWidth: true
                        Layout.maximumHeight: units.gridUnit*2
                    }
                    Kirigami.Heading { // Song Album
                        color: (softwareRendering || !albumArt.visible) ? PlasmaCore.ColorScope.textColor : "white"

                        level: 3
                        opacity: 0.6

                        textFormat: Text.PlainText
                        wrapMode: Text.Wrap
                        fontSizeMode: Text.VerticalFit
275
                        elide: Text.ElideRight
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296

                        visible: text.length !== 0
                        text: {
                            var metadata = root.currentMetadata
                            if (!metadata) {
                                return ""
                            }
                            var xesamAlbum = metadata["xesam:album"]
                            if (xesamAlbum) {
                                return xesamAlbum
                            }

                            // if we play a local file without title and artist, show its containing folder instead
                            if (metadata["xesam:title"] || root.artist) {
                                return ""
                            }

                            var xesamUrl = (metadata["xesam:url"] || "").toString()
                            if (xesamUrl.indexOf("file:///") !== 0) { // "!startsWith()"
                                return ""
                            }
297

298
299
300
301
                            var urlParts = xesamUrl.split("/")
                            if (urlParts.length < 3) {
                                return ""
                            }
302

303
304
305
306
                            var lastFolderPath = urlParts[urlParts.length - 2] // last would be filename
                            if (lastFolderPath) {
                                return lastFolderPath
                            }
307

308
309
310
311
312
313
                            return ""
                        }
                        Layout.fillWidth: true
                        Layout.maximumHeight: units.gridUnit*2
                    }
                }
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
314
            }
315
        }
316

317
318
319
320
321
        Item {
            implicitHeight: units.smallSpacing
        }

        RowLayout { // Seek Bar
322
323
324
            spacing: units.smallSpacing

            // if there's no "mpris:length" in the metadata, we cannot seek, so hide it in that case
325
            enabled: !root.noPlayer && root.track && expandedRepresentation.length > 0 ? true : false
326
327
328
329
            opacity: enabled ? 1 : 0
            Behavior on opacity {
                NumberAnimation { duration: units.longDuration }
            }
330

331
332
333
334
            Layout.alignment: Qt.AlignHCenter
            Layout.fillWidth: true
            Layout.maximumWidth: Math.min(units.gridUnit*45, Math.round(expandedRepresentation.width*(7/10)))

335
336
337
338
            // ensure the layout doesn't shift as the numbers change and measure roughly the longest text that could occur with the current song
            TextMetrics {
                id: timeMetrics
                text: i18nc("Remaining time for song e.g -5:42", "-%1",
Marco Martin's avatar
Marco Martin committed
339
                            KCoreAddons.Format.formatDuration(seekSlider.to / 1000, expandedRepresentation.durationFormattingOptions))
340
                font: theme.smallestFont
341
342
            }

343
            PlasmaComponents3.Label { // Time Elapsed
344
345
346
                Layout.preferredWidth: timeMetrics.width
                verticalAlignment: Text.AlignVCenter
                horizontalAlignment: Text.AlignRight
347
                text: KCoreAddons.Format.formatDuration(seekSlider.value / 1000, expandedRepresentation.durationFormattingOptions)
348
                opacity: 0.9
349
                font: theme.smallestFont
350
                color: PlasmaCore.ColorScope.textColor
351
352
            }

353
            PlasmaComponents3.Slider { // Slider
354
355
356
357
                id: seekSlider
                Layout.fillWidth: true
                z: 999
                value: 0
358
                visible: canSeek
359

Marco Martin's avatar
Marco Martin committed
360
                onMoved: {
361
362
363
364
365
366
367
368
                    if (!disablePositionUpdate) {
                        // delay setting the position to avoid race conditions
                        queuedPositionUpdate.restart()
                    }
                }

                Timer {
                    id: seekTimer
369
                    interval: 1000 / expandedRepresentation.rate
370
                    repeat: true
Laurent Montel's avatar
Laurent Montel committed
371
                    running: root.state === "playing" && plasmoid.expanded && !keyPressed && interval > 0 && seekSlider.to >= 1000000
372
373
374
375
376
                    onTriggered: {
                        // some players don't continuously update the seek slider position via mpris
                        // add one second; value in microseconds
                        if (!seekSlider.pressed) {
                            disablePositionUpdate = true
Marco Martin's avatar
Marco Martin committed
377
                            if (seekSlider.value == seekSlider.to) {
378
379
380
381
382
                                retrievePosition();
                            } else {
                                seekSlider.value += 1000000
                            }
                            disablePositionUpdate = false
383
                        }
384
                    }
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
385
                }
386
            }
387

388
            RowLayout {
389
                visible: !canSeek
390
391
392
393
394
395
396
397
398
399
400
401
402

                Layout.fillWidth: true
                Layout.preferredHeight: seekSlider.height

                PlasmaComponents3.ProgressBar { // Time Remaining
                    value: seekSlider.value
                    from: seekSlider.from
                    to: seekSlider.to

                    Layout.fillWidth: true
                    Layout.fillHeight: false
                    Layout.alignment: Qt.AlignVCenter
                }
403
404
            }

Marco Martin's avatar
Marco Martin committed
405
            PlasmaComponents3.Label {
406
407
                Layout.preferredWidth: timeMetrics.width
                verticalAlignment: Text.AlignVCenter
408
                horizontalAlignment: Text.AlignLeft
409
                text: i18nc("Remaining time for song e.g -5:42", "-%1",
Marco Martin's avatar
Marco Martin committed
410
                            KCoreAddons.Format.formatDuration((seekSlider.to - seekSlider.value) / 1000, expandedRepresentation.durationFormattingOptions))
411
                opacity: 0.9
412
                font: theme.smallestFont
413
                color: PlasmaCore.ColorScope.textColor
414
            }
415
        }
416

417
418
        Row { // Player Controls
            id: playerControls
419

420
421
            property bool enabled: root.canControl
            property int controlsSize: theme.mSize(theme.defaultFont).height * 3
422

423
424
            Layout.alignment: Qt.AlignHCenter
            spacing: units.largeSpacing
425

426
427
428
429
430
431
432
433
434
435
436
            PlasmaComponents3.ToolButton { // Previous
                anchors.verticalCenter: parent.verticalCenter
                width: expandedRepresentation.controlSize
                height: width
                enabled: playerControls.enabled && root.canGoPrevious
                icon.name: LayoutMirroring.enabled ? "media-skip-forward" : "media-skip-backward"
                onClicked: {
                    seekSlider.value = 0    // Let the media start from beginning. Bug 362473
                    root.action_previous()
                }
            }
437

438
439
440
441
442
443
444
            PlasmaComponents3.ToolButton { // Pause/Play
                width: Math.round(expandedRepresentation.controlSize * 1.5)
                height: width
                enabled: root.state == "playing" ? root.canPause : root.canPlay
                icon.name: root.state == "playing" ? "media-playback-pause" : "media-playback-start"
                onClicked: root.togglePlaying()
            }
445

446
447
448
449
450
451
452
453
454
            PlasmaComponents3.ToolButton { // Next
                anchors.verticalCenter: parent.verticalCenter
                width: expandedRepresentation.controlSize
                height: width
                enabled: playerControls.enabled && root.canGoNext
                icon.name: LayoutMirroring.enabled ? "media-skip-backward" : "media-skip-forward"
                onClicked: {
                    seekSlider.value = 0    // Let the media start from beginning. Bug 362473
                    root.action_next()
455
456
457
458
                }
            }
        }

459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
        PlasmaComponents3.ComboBox {
            Layout.fillWidth: true
            Layout.leftMargin: units.gridUnit*2
            Layout.rightMargin: units.gridUnit*2

            id: playerCombo
            textRole: "text"
            visible: model.length > 2 // more than one player, @multiplex is always there
            model: root.mprisSourcesModel

            onModelChanged: {
                // if model changes, ComboBox resets, so we try to find the current player again...
                for (var i = 0, length = model.length; i < length; ++i) {
                    if (model[i].source === mpris2Source.current) {
                        currentIndex = i
                        break
475
476
                    }
                }
477
            }
478

479
480
481
482
483
            onActivated: {
                disablePositionUpdate = true
                // ComboBox has currentIndex and currentText, why doesn't it have currentItem/currentModelValue?
                mpris2Source.current = model[index].source
                disablePositionUpdate = false
484
485
            }
        }
486
487
    }

Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
488
489
490
491
    Timer {
        id: queuedPositionUpdate
        interval: 100
        onTriggered: {
492
493
494
            if (position == seekSlider.value) {
                return;
            }
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
495
496
497
498
499
500
            var service = mpris2Source.serviceForSource(mpris2Source.current)
            var operation = service.operationDescription("SetPosition")
            operation.microseconds = seekSlider.value
            service.startOperationCall(operation)
        }
    }
Kai Uwe Broulik's avatar
Kai Uwe Broulik committed
501
}