MediaPlayListView.qml 22.3 KB
Newer Older
1
/*
Matthieu Gallien's avatar
Matthieu Gallien committed
2
3
4
5
   SPDX-FileCopyrightText: 2016 (c) Matthieu Gallien <matthieu_gallien@yahoo.fr>
   SPDX-FileCopyrightText: 2019 (c) Nate Graham <nate@kde.org>

   SPDX-License-Identifier: LGPL-3.0-or-later
6
7
 */

Devin Lin's avatar
Devin Lin committed
8
import QtQuick 2.15
9
import QtQuick.Controls 2.3
10
11
import QtQuick.Layouts 1.1
import QtQuick.Window 2.2
12
import QtQml.Models 2.1
13
import Qt.labs.platform 1.0 as PlatformDialog
14
import org.kde.kirigami 2.15 as Kirigami
15
import org.kde.elisa 1.0
16

Devin Lin's avatar
Devin Lin committed
17
18
import "mobile"

19
20
21
22
// Not using ScrollablePage because we don't need any of the refresh features
// that it provides
Kirigami.Page {
    id: topItem
23

24
    signal startPlayback()
25
    signal pausePlayback()
26

Devin Lin's avatar
Devin Lin committed
27
28
29
30
    // set by the respective mobile/desktop view
    property var playListNotification
    property var playListView

31
32
33
34
    function hideNotification() {
        playListNotification.visible = false;
    }

35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
    function showPlayListNotification(message, type, action) {
        if (!message) {
            return;
        }

        if (type) {
            playListNotification.type = type;
        } else {
            playListNotification.type = Kirigami.MessageType.Information;
        }

        if (action) {
            playListNotification.actions = action;
        } else {
            playListNotification.actions = [];
        }

        playListNotification.text = message ? message : "";
        playListNotification.visible = true;
    }

56
57
58
59
60
61
62
63
64
65

    title: i18nc("@info Title of the view of the playlist; keep this string as short as possible because horizontal space is quite scarce", "Playlist")
    padding: 0

    // Use view colors so the background is white
    Kirigami.Theme.inherit: false
    Kirigami.Theme.colorSet: Kirigami.Theme.View

    Accessible.role: Accessible.Pane
    Accessible.name: topItem.title
66

Devin Lin's avatar
Devin Lin committed
67
68
69
70
71
72
    Timer {
        id: mobileClearedMessageTimer
        interval: 3000
        onTriggered: mobileClearedMessage.visible = false
    }

73
74
75
76
    Kirigami.Action {
        id: undoAction
        text: i18nc("Undo", "Undo")
        icon.name: "dialog-cancel"
77
        onTriggered: ElisaApplication.mediaPlayListProxyModel.undoClearPlayList()
78
79
80
81
82
83
    }

    Kirigami.Action {
        id: retryLoadAction
        text: i18nc("Retry", "Retry")
        icon.name: "edit-redo"
84
        onTriggered: loadPlaylistButton.clicked()
85
86
87
88
89
90
    }

    Kirigami.Action {
        id: retrySaveAction
        text: i18nc("Retry", "Retry")
        icon.name: "edit-redo"
91
        onTriggered: savePlaylistButton.clicked()
92
93
94
    }

    Connections {
95
        target: ElisaApplication.mediaPlayListProxyModel
96
        function onPlayListLoadFailed() {
97
98
99
100
101
            showPlayListNotification(i18nc("Message when playlist load failed", "Loading failed"), Kirigami.MessageType.Error, retryLoadAction)
        }
    }

    Connections {
102
         target: ElisaApplication.mediaPlayListProxyModel
103
         function onDisplayUndoNotification() {
Devin Lin's avatar
Devin Lin committed
104
105
106
107
108
109
110
             if (Kirigami.Settings.isMobile) {
                 // cleared playlist message
                mobileClearedMessage.visible = true;
                mobileClearedMessageTimer.restart();
            } else {
                showPlayListNotification(i18nc("Playlist cleared", "Playlist cleared"), Kirigami.MessageType.Information, undoAction);
            }
111
112
113
114
         }
    }

    Connections {
115
         target: ElisaApplication.mediaPlayListProxyModel
116
117
118
         function onHideUndoNotification() {
            hideNotification()
         }
119
    }
120

121
122
123
    // TODO: Once we depend on Frameworks 5.80, change this to
    // "Kirigami.ApplicationHeaderStyle.None" and remove the custom header
    globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None
124
125
126
127
128
129
    header: ToolBar {
        // Override color to use standard window colors, not header colors
        // TODO: remove this if the HeaderBar component is ever removed or moved
        // to the bottom of the window such that this toolbar touches the window
        // titlebar
        Kirigami.Theme.colorSet: Kirigami.Theme.Window
Devin Lin's avatar
Devin Lin committed
130
        implicitHeight: Math.round(Kirigami.Units.gridUnit * 2.5)
131
        leftPadding: Kirigami.Units.largeSpacing
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185

        RowLayout {
            anchors.fill: parent

            Kirigami.Heading {
                text: topItem.title
            }
            Kirigami.ActionToolBar {
                Layout.fillWidth: true
                alignment: Qt.AlignRight

                actions: [
                    Kirigami.Action {
                        text: i18nc("Show currently played track inside playlist", "Show Current Track")
                        icon.name: 'media-track-show-active'
                        displayHint: Kirigami.DisplayHint.KeepVisible | Kirigami.DisplayHint.IconOnly
                        enabled: ElisaApplication.mediaPlayListProxyModel ? ElisaApplication.mediaPlayListProxyModel.tracksCount > 0 : false
                        onTriggered: {
                            playListView.positionViewAtIndex(ElisaApplication.mediaPlayListProxyModel.currentTrackRow, ListView.Contain)
                            playListView.currentIndex = ElisaApplication.mediaPlayListProxyModel.currentTrackRow
                            playListView.currentItem.forceActiveFocus()
                        }
                    },
                    Kirigami.Action {
                        id: savePlaylistButton
                        text: i18nc("Save a playlist file", "Save Playlist...")
                        icon.name: 'document-save'
                        displayHint: Kirigami.DisplayHint.KeepVisible | Kirigami.DisplayHint.IconOnly
                        enabled: ElisaApplication.mediaPlayListProxyModel ? ElisaApplication.mediaPlayListProxyModel.tracksCount > 0 : false
                        onTriggered: {
                            fileDialog.fileMode = PlatformDialog.FileDialog.SaveFile
                            fileDialog.file = ''
                            fileDialog.open()
                        }
                    },
                    Kirigami.Action {
                        id: loadPlaylistButton
                        text: i18nc("Load a playlist file", "Load Playlist...")
                        icon.name: 'document-open'
                        displayHint: Kirigami.DisplayHint.KeepVisible | Kirigami.DisplayHint.IconOnly
                        onTriggered: {
                            fileDialog.fileMode = PlatformDialog.FileDialog.OpenFile
                            fileDialog.file = ''
                            fileDialog.open()
                        }
                    },
                    Kirigami.Action {
                        text: i18nc("Remove all tracks from play list", "Clear Playlist")
                        icon.name: 'edit-clear-all'
                        displayHint: Kirigami.DisplayHint.KeepVisible | Kirigami.DisplayHint.IconOnly
                        enabled: ElisaApplication.mediaPlayListProxyModel ? ElisaApplication.mediaPlayListProxyModel.tracksCount > 0 : false
                        onTriggered: ElisaApplication.mediaPlayListProxyModel.clearPlayList()
                    }
                ]
186
187
188
189
            }
        }
    }

Devin Lin's avatar
Devin Lin committed
190
    ColumnLayout {
191
192
        anchors.fill: parent

Devin Lin's avatar
Devin Lin committed
193
194
195
196
197
198
199
        // ========== desktop listview ==========
        Component {
            id: desktopListView
            ScrollView {
                property alias list: playListView
                ListView {
                    id: playListView
Tranter Madi's avatar
Tranter Madi committed
200
                    readonly property alias sectionSizer: sectionSizer
201

Devin Lin's avatar
Devin Lin committed
202
203
204
205
                    focus: true
                    clip: true
                    keyNavigationEnabled: true
                    activeFocusOnTab: true
206

Devin Lin's avatar
Devin Lin committed
207
                    currentIndex: -1
208

Devin Lin's avatar
Devin Lin committed
209
210
                    Accessible.role: Accessible.List
                    Accessible.name: topItem.title
211

Devin Lin's avatar
Devin Lin committed
212
213
214
215
216
217
218
                    section.property: 'albumSection'
                    section.criteria: ViewSection.FullString
                    section.labelPositioning: ViewSection.InlineLabels
                    section.delegate: BasicPlayListAlbumHeader {
                        headerData: JSON.parse(section)
                        width: playListView.width
                    }
219

Devin Lin's avatar
Devin Lin committed
220
221
222
223
224
225
226
227
228
229
230
                    /* currently disabled animations due to display corruption
                    because of https://bugreports.qt.io/browse/QTBUG-49868
                    causing https://bugs.kde.org/show_bug.cgi?id=406524
                    and https://bugs.kde.org/show_bug.cgi?id=398093
                    add: Transition {
                        NumberAnimation {
                            property: "opacity";
                            from: 0;
                            to: 1;
                            duration: Kirigami.Units.shortDuration }
                    }
231

Devin Lin's avatar
Devin Lin committed
232
233
234
235
236
237
238
                    populate: Transition {
                        NumberAnimation {
                            property: "opacity";
                            from: 0;
                            to: 1;
                            duration: Kirigami.Units.shortDuration }
                    }
239

Devin Lin's avatar
Devin Lin committed
240
241
242
243
244
245
246
                    remove: Transition {
                        NumberAnimation {
                            property: "opacity";
                            from: 1.0;
                            to: 0;
                            duration: Kirigami.Units.shortDuration }
                    }
247

Devin Lin's avatar
Devin Lin committed
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
                    displaced: Transition {
                        NumberAnimation {
                            properties: "x,y";
                            duration: Kirigami.Units.shortDuration
                            easing.type: Easing.InOutQuad }
                    }
                    */

                    model: DelegateModel {
                        model: ElisaApplication.mediaPlayListProxyModel

                        groups: [
                            DelegateModelGroup { name: "selected" }
                        ]

                        delegate: DraggableItem {
                            id: item
                            width: playListView.width
                            placeholderHeight: elisaTheme.dragDropPlaceholderHeight

                            focus: true

                            PlayListEntry {
                                id: entry

                                focus: true

                                width: parent.width

                                index: model.index
                                isAlternateColor: item.DelegateModel.itemsIndex % 2
                                isSelected: playListView.currentIndex === index
                                containsMouse: item.containsMouse

                                databaseId: model.databaseId ? model.databaseId : 0
                                entryType: model.entryType ? model.entryType : ElisaUtils.Unknown
                                title: model.title ? model.title : ''
                                artist: model.artist ? model.artist : ''
                                album: model.album ? model.album : ''
                                albumArtist: model.albumArtist ? model.albumArtist : ''
                                duration: model.duration ? model.duration : ''
                                fileName: model.trackResource ? model.trackResource : ''
                                imageUrl: model.imageUrl ? model.imageUrl : ''
                                trackNumber: model.trackNumber ? model.trackNumber : -1
                                discNumber: model.discNumber ? model.discNumber : -1
                                rating: model.rating ? model.rating : 0
                                isSingleDiscAlbum: model.isSingleDiscAlbum !== undefined ? model.isSingleDiscAlbum : true
                                isValid: model.isValid
                                isPlaying: model.isPlaying
                                metadataModifiableRole: model ? model.metadataModifiableRole : false

                                onStartPlayback: topItem.startPlayback()
                                onPausePlayback: topItem.pausePlayback()
                                onRemoveFromPlaylist: ElisaApplication.mediaPlayListProxyModel.removeRow(trackIndex)
                                onSwitchToTrack: ElisaApplication.mediaPlayListProxyModel.switchTo(trackIndex)

                                onActiveFocusChanged: {
                                    if (activeFocus && playListView.currentIndex !== index) {
                                        playListView.currentIndex = index
                                    }
                                }
                            }
310

Devin Lin's avatar
Devin Lin committed
311
                            draggedItemParent: playListView
312

Devin Lin's avatar
Devin Lin committed
313
314
315
316
                            onClicked: {
                                playListView.currentIndex = index
                                entry.forceActiveFocus()
                            }
317

Devin Lin's avatar
Devin Lin committed
318
319
320
321
322
323
                            onDoubleClicked: {
                                if (model.isValid) {
                                    ElisaApplication.mediaPlayListProxyModel.switchTo(model.index)
                                    topItem.startPlayback()
                                }
                            }
324

Devin Lin's avatar
Devin Lin committed
325
326
                            onMoveItemRequested: {
                                ElisaApplication.mediaPlayListProxyModel.moveRow(from, to);
327
328
                            }
                        }
329
                    }
330

Devin Lin's avatar
Devin Lin committed
331
332
333
                    onCountChanged: if (count === 0) {
                        currentIndex = -1;
                    }
334

Devin Lin's avatar
Devin Lin committed
335
336
337
                    Kirigami.PlaceholderMessage {
                        anchors.centerIn: parent
                        width: parent.width - (Kirigami.Units.largeSpacing * 4)
338
339
                        text: i18n("Playlist is empty")
                        explanation: i18n("Add some songs to get started. You can browse your music using the views on the left.")
Devin Lin's avatar
Devin Lin committed
340
                        visible: playListView.count === 0
341
                    }
342

Devin Lin's avatar
Devin Lin committed
343
344
345
346
347
348
349
350
351
                    Kirigami.InlineMessage {
                        id: playListNotification
                        Component.onCompleted: topItem.playListNotification = playListNotification

                        anchors {
                            left: parent.left
                            right: parent.right
                            bottom: parent.bottom
                            margins: Kirigami.Units.largeSpacing
352
                        }
Diego Gangl's avatar
Diego Gangl committed
353

Devin Lin's avatar
Devin Lin committed
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
                        type: Kirigami.MessageType.Information
                        showCloseButton: true

                        onVisibleChanged: {
                            if (visible) {
                                autoHideNotificationTimer.start()
                            } else {
                                autoHideNotificationTimer.stop()
                            }
                        }

                        Timer {
                            id: autoHideNotificationTimer
                            interval: 7000
                            onTriggered: playListNotification.visible = false
                        }
370
                    }
Tranter Madi's avatar
Tranter Madi committed
371
372
373
374
375
376
377
378
379
380

                    // calculate a fixed hight for section delegates
                    // workaround for QTBUG-52595
                    Column {
                        id: sectionSizer
                        visible: false
                        spacing: Kirigami.Units.smallSpacing
                        LabelWithToolTip { text: "M\nM"; level: 2 }
                        LabelWithToolTip { text: "M" }
                    }
381
382
                }
            }
Devin Lin's avatar
Devin Lin committed
383
        }
Diego Gangl's avatar
Diego Gangl committed
384

Devin Lin's avatar
Devin Lin committed
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
        // ========== mobile delegate ==========
        Component {
            id: mobileDelegateComponent
            MobilePlayListDelegate {
                property var model
                width: parent ? parent.width : topItem.width

                index: model ? model.index : 0
                isAlternateColor: playListView.currentIndex % 2
                isSelected: playListView.currentIndex === index

                databaseId: model && model.databaseId ? model.databaseId : 0
                entryType: model && model.entryType ? model.entryType : ElisaUtils.Unknown
                title: model ? model.title || '' : ''
                artist: model ? model.artist || '' : ''
                album: model ? model.album || '' : ''
                albumArtist: model ? model.albumArtist || '' : ''
                duration: model ? model.duration || '' : ''
                fileName: model ? model.trackResource || '' : ''
                imageUrl: model ? model.imageUrl || '' : ''
                trackNumber: model ? model.trackNumber || -1 : -1
                discNumber: model ? model.discNumber || -1 : -1
                rating: model ? model.rating || 0 : 0
                isSingleDiscAlbum: model && model.isSingleDiscAlbum !== undefined ? model.isSingleDiscAlbum : true
                isValid: model && model.isValid
                isPlaying: model ? model.isPlaying : false
                metadataModifiableRole: model ? model.metadataModifiableRole : false
                hideDiscNumber: model && model.isSingleDiscAlbum

                listView: playListView

                onStartPlayback: topItem.startPlayback()
                onPausePlayback: topItem.pausePlayback()
                onRemoveFromPlaylist: ElisaApplication.mediaPlayListProxyModel.removeRow(trackIndex)
                onSwitchToTrack: ElisaApplication.mediaPlayListProxyModel.switchTo(trackIndex)

                onActiveFocusChanged: {
                    if (activeFocus && playListView.currentIndex !== index) {
                        playListView.currentIndex = index
                    }
                }
426

Devin Lin's avatar
Devin Lin committed
427
428
429
                onClicked: {
                    playListView.currentIndex = index
                    forceActiveFocus()
Diego Gangl's avatar
Diego Gangl committed
430

Devin Lin's avatar
Devin Lin committed
431
432
433
434
435
436
437
438
439
                    if (model.isValid) {
                        if (model.isPlaying === MediaPlayList.IsPlaying) {
                            topItem.pausePlayback()
                        } else {
                            ElisaApplication.mediaPlayListProxyModel.switchTo(model.index)
                            topItem.startPlayback()
                        }
                    }
                }
Diego Gangl's avatar
Diego Gangl committed
440

Devin Lin's avatar
Devin Lin committed
441
442
                onMoveItemRequested: {
                    ElisaApplication.mediaPlayListProxyModel.moveRow(from, to);
443
                }
Devin Lin's avatar
Devin Lin committed
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
            }
        }

        // ========== mobile listview ==========
        Component {
            id: mobileListView
            ScrollView {
                property alias list: playListView
                ListView {
                    id: playListView
                    anchors.fill: parent
                    reuseItems: true

                    model: ElisaApplication.mediaPlayListProxyModel

                    moveDisplaced: Transition {
                        YAnimator {
                            duration: Kirigami.Units.longDuration
                            easing.type: Easing.InOutQuad
                        }
                    }
465

Devin Lin's avatar
Devin Lin committed
466
467
468
469
470
471
472
                    Kirigami.PlaceholderMessage {
                        anchors.centerIn: parent
                        anchors.left: parent.left
                        anchors.right: parent.right
                        anchors.margins: Kirigami.Units.largeSpacing
                        width: parent.width - (Kirigami.Units.largeSpacing * 4)
                        visible: ElisaApplication.mediaPlayListProxyModel ? ElisaApplication.mediaPlayListProxyModel.tracksCount === 0 : true
473

Devin Lin's avatar
Devin Lin committed
474
475
                        icon.name: "view-media-playlist"
                        text: xi18nc("@info", "Your playlist is empty.")
476
                    }
477

Devin Lin's avatar
Devin Lin committed
478
479
480
481
482
483
484
485
486
                    delegate: Loader {
                        // apparently it's possible for parent to be null, set to undefined to ignore warning
                        anchors.left: parent ? parent.left : undefined
                        anchors.right: parent ? parent.right : undefined
                        sourceComponent: mobileDelegateComponent
                        onLoaded: {
                            item.model = model;
                        }
                    }
487
488
                }
            }
489
        }
Devin Lin's avatar
Devin Lin committed
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513

        Loader {
            id: playListLoader
            Layout.fillWidth: true
            Layout.fillHeight: true
            sourceComponent: Kirigami.Settings.isMobile ? mobileListView : desktopListView
            onLoaded: playListView = item.list
        }

        Kirigami.InlineMessage {
            id: mobileClearedMessage
            Layout.fillWidth: true
            visible: false
            showCloseButton: true
            text: i18nc("Playlist cleared", "Playlist cleared")

            actions: [
                Kirigami.Action {
                    text: i18n("Undo")
                    icon.name: "edit-undo-symbolic"
                    onTriggered: ElisaApplication.mediaPlayListProxyModel.undoClearPlayList()
                }
            ]
        }
514
    }
515

516
517
    footer: ToolBar {
        implicitHeight: Math.round(Kirigami.Units.gridUnit * 2)
518
519
        leftPadding: Kirigami.Units.largeSpacing
        rightPadding: Kirigami.Units.largeSpacing
520

521
522
        RowLayout {
            anchors.fill: parent
523

524
525
526
527
528
529
            LabelWithToolTip {
                text: i18np("%1 track", "%1 tracks", (ElisaApplication.mediaPlayListProxyModel ? ElisaApplication.mediaPlayListProxyModel.tracksCount : 0))
                elide: Text.ElideLeft
            }
            Item {
                Layout.fillWidth: true
530
            }
531
532
            LabelWithToolTip {
                visible: ElisaApplication.mediaPlayListProxyModel.remainingTracks != -1
533

534
535
                text: ElisaApplication.mediaPlayListProxyModel.remainingTracks == 0 ? i18n("Last track") : i18ncp("Number of remaining tracks in a playlist of songs", "%1 remaining", "%1 remaining", ElisaApplication.mediaPlayListProxyModel.remainingTracks)
                elide: Text.ElideRight
536
            }
537
        }
538
539
540
541
542
    }

    PlatformDialog.FileDialog {
        id: fileDialog

543
        defaultSuffix: 'm3u8'
544
        folder: PlatformDialog.StandardPaths.writableLocation(PlatformDialog.StandardPaths.MusicLocation)
545
        nameFilters: [i18nc("file type (mime type) for m3u and m3u8 playlist file formats", "Playlist (*.m3u*)")]
546

547
548
549
550
551
        onAccepted:
        {
            if (fileMode === PlatformDialog.FileDialog.SaveFile) {
                if (!ElisaApplication.mediaPlayListProxyModel.savePlayList(fileDialog.file)) {
                    showPlayListNotification(i18nc("Message when saving a playlist failed", "Saving failed"), Kirigami.MessageType.Error, retrySaveAction)
552
                }
553
554
555
            } else {
                ElisaApplication.mediaPlayListProxyModel.loadPlayList(fileDialog.file)
            }
556
        }
557
558
559
    }
}