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 * 15
    Layout.minimumHeight: units.gridUnit * 23
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
}