1/*
2 * Copyright (C) 2016-2019
3 *      Jean-Luc Barriere <jlbarriere68@gmail.com>
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; version 3.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 */
17
18import QtQuick 2.9
19import QtQuick.Layouts 1.3
20import QtQuick.Controls 2.2
21import QtQuick.Controls.Material 2.2
22import QtQuick.Controls.Universal 2.2
23import Qt.labs.settings 1.0
24import QtGraphicalEffects 1.0
25import NosonApp 1.0
26import NosonThumbnailer 1.0
27import "components"
28import "components/Dialog"
29
30ApplicationWindow {
31    id: mainView
32    visible: true
33    title: "noson"
34
35    // Design stuff
36    width: 360
37    height: 640
38
39    Settings {
40        id: settings
41        property string style: "Material"
42        property int theme: 0
43
44        property real scaleFactor: 1.0
45        property real fontScaleFactor: 1.0
46        property bool firstRun: true
47        property string zoneName: ""
48        property string coordinatorName: ""
49        property int tabIndex: -1
50        property int widthGU: Math.round(mainView.width / units.gridUnit)
51        property int heightGU: Math.round(mainView.height / units.gridUnit)
52        property string accounts: ""
53        property string lastfmKey: ""
54        property string deviceUrl: ""
55        property string musicLocation: ""
56    }
57
58    Material.accent: Material.Grey
59    Universal.accent: "grey"
60
61    //@FIXME: declare the property 'palette' that is missing in QtQuick.controls 2.2 (Qt-5.9)
62    Item {
63        id: palette
64        property color base: {
65            if (settings.style === "Material") {
66                return Material.background
67            } else if (settings.style === "Universal") {
68                return Universal.background
69            } else return "white"
70        }
71        property color text: {
72            if (settings.style === "Material") {
73                return Material.foreground
74            } else if (settings.style === "Universal") {
75                return Universal.foreground
76            } else return "black"
77        }
78        property color highlight: "gray"
79        property color shadow: "black"
80        property color brightText: "dimgray"
81        property color button: "darkgray"
82        property color link: "green"
83        property color toolTipBase: "black"
84        property color toolTipText: "white"
85    }
86
87    StyleLight {
88        id: styleMusic
89    }
90
91    Universal.theme: settings.theme
92    Material.theme: settings.theme
93
94    Units {
95        id: units
96        scaleFactor: settings.scaleFactor
97        fontScaleFactor: settings.fontScaleFactor
98    }
99
100    PopInfo {
101        id: popInfo
102        backgroundColor: styleMusic.popover.backgroundColor
103        labelColor: styleMusic.popover.labelColor
104    }
105
106    DialogSettings {
107        id: dialogSonosSettings
108    }
109
110    Loader {
111        id: zonesPageLoader
112        asynchronous: true
113        source: "qrc:/controls2/Zones.qml"
114        visible: false
115    }
116
117    // The player handles all actions to control the music
118    Player {
119        id: player
120    }
121
122    // Variables
123    property string appName: "Noson"    // My name
124    property int debugLevel: 2          // My debug level
125    property bool playOnStart: false    // play inputStreamUrl when startup is completed
126    property bool startup: true         // is running the cold startup ?
127    property bool ssdp: true            // point out the connect method
128
129    // Property to store the state of the application (active or suspended)
130    property bool applicationSuspended: false
131
132    // setting alias to check first run
133    property alias firstRun: settings.firstRun
134
135    // setting alias to store deviceUrl as hint for the SSDP discovery
136    property alias deviceUrl: settings.deviceUrl
137
138    // setting alias to store last zone connected
139    property alias currentZone: settings.zoneName
140    property alias currentCoordinator: settings.coordinatorName
141    property string currentZoneTag: ""
142
143    // track latest stream link
144    property string inputStreamUrl: ""
145
146    // No source configured: First user has to select a source
147    property bool noMusic: player.currentMetaSource === "" && loadedUI
148
149    // No zone connected: UI push page "NoZoneState" on top to invit user to retry discovery of Sonos devices
150    property bool noZone: false // doesn't pop page on startup
151    property Page noZonePage
152
153    // current page now playing
154    property Page nowPlayingPage
155
156    // property to detect if the UI has finished
157    property bool loadedUI: false
158    property real wideSongView: units.gu(70)
159    property bool wideAspect: width >= units.gu(100) && loadedUI
160
161    // property to enable pop info on index loaded
162    property bool infoLoadedIndex: true // enabled at startup
163
164    // property to detect thumbnailer is available
165    property bool thumbValid: false
166
167    // Constants
168    readonly property int queueBatchSize: 100
169    readonly property real minSizeGU: 42
170    readonly property string tr_undefined: qsTr("<Undefined>")
171
172    minimumHeight: units.gu(minSizeGU)
173    minimumWidth: units.gu(minSizeGU)
174
175    // built-in cache for genre artworks
176    property var genreArtworks: []
177
178    // about alarms
179    AlarmsModel {
180        id: alarmsModel
181        property bool updatePending: false
182        property bool dataSynced: false
183
184        onDataUpdated: asyncLoad()
185
186        onLoaded: {
187            if (updatePending) {
188                // delay model reset while a dialog still opened
189                dataSynced = false;
190            } else {
191                resetModel();
192                dataSynced = true;
193            }
194        }
195
196        onUpdatePendingChanged: {
197            if (!updatePending && !dataSynced) {
198                resetModel();
199                dataSynced = true;
200            }
201        }
202
203        onCountChanged: alarmEnabled = isAlarmEnabled()
204    }
205
206    ////////////////////////////////////////////////////////////////////////////
207    ////
208    //// Events
209    ////
210
211    Connections {
212        target: Qt.application
213        function onStateChanged() {
214            if (Qt.application.state === Qt.ApplicationSuspended)
215                applicationSuspended = true;
216            else if (applicationSuspended === true) {
217                applicationSuspended = false;
218                if (!noZone) {
219                    player.ping(function(result) {
220                        if (result) {
221                            customdebug("Renew all subscriptions");
222                            var future = Sonos.tryRenewSubscriptions();
223                            future.finished.connect(actionFinished);
224                            future.start();
225                        } else {
226                            noZone = true;
227                        }
228                    });
229                }
230            }
231        }
232    }
233
234    Connections {
235        target: Sonos
236
237        function onJobCountChanged() { jobRunning = Sonos.jobCount > 0 ? true : false }
238
239        function onInitDone(succeeded) {
240            if (succeeded) {
241                // clear the setting deviceUrl when ssdp method succeeded
242                if (ssdp && deviceUrl !== "") {
243                    customdebug("NOTICE: Clearing the configured URL because invalid");
244                    deviceUrl = "";
245                }
246                if (noZone)
247                    noZone = false;
248            } else {
249                if (!noZone)
250                    noZone = true;
251            }
252        }
253
254        function onLoadingFinished() {
255            if (infoLoadedIndex) {
256                infoLoadedIndex = false;
257                popInfo.open(qsTr("Index loaded"));
258            }
259        }
260
261        function onTopologyChanged() {
262            AllZonesModel.asyncLoad();
263        }
264    }
265
266    // Run on startup
267    Component.onCompleted: {
268        var argno = 0;
269        if (indexOfArgument("--debug") > 0) {
270            mainView.debugLevel = 4;
271        }
272        // Argument --playurl={Stream URL}: Play URL at startup
273        if ((argno = indexOfArgument("--playurl=")) > 0) {
274            inputStreamUrl = ApplicationArguments[argno].slice(ApplicationArguments[argno].indexOf("=") + 1);
275            playOnStart = (inputStreamUrl.length > 0);
276            customdebug(argno + ": playurl=" + inputStreamUrl);
277        }
278        // Argument --zone={Zone name}: Connect to zone at startup
279        if ((argno = indexOfArgument("--zone=")) > 0) {
280            currentZone = ApplicationArguments[argno].slice(ApplicationArguments[argno].indexOf("=") + 1);
281            customdebug(argno + ": zone=" + currentZone);
282        }
283        // Argument --deviceurl={http://host:port[/xml/device_description.xml]}: Hint for the SSDP discovery
284        if ((argno = indexOfArgument("--deviceurl=")) > 0) {
285            deviceUrl = ApplicationArguments[argno].slice(ApplicationArguments[argno].indexOf("=") + 1);
286            customdebug(argno + ": deviceurl=" + deviceUrl);
287        }
288
289        customdebug("LANG=" + Qt.locale().name);
290        Sonos.setLocale(Qt.locale().name);
291
292        // configure the thumbnailer
293        if (settings.lastfmKey && settings.lastfmKey.length > 1) {
294            if (Thumbnailer.configure("LASTFM", settings.lastfmKey))
295                thumbValid = true;
296        } else {
297            if (Thumbnailer.configure("DEEZER", "n/a"))
298                thumbValid = true;
299        }
300
301        // init SMAPI third party accounts
302        var acls = deserializeACLS(settings.accounts);
303        for (var i = 0; i < acls.length; ++i) {
304            customdebug("register account: type=" + acls[i].type + " sn=" + acls[i].sn + " token=" + acls[i].token.substr(0, 1) + "...");
305            Sonos.addServiceOAuth(acls[i].type, acls[i].sn, acls[i].key, acls[i].token, acls[i].username);
306        }
307
308        // initialize all data models
309        AllZonesModel.init(Sonos, "",       false);
310        AllFavoritesModel.init(Sonos, "",   false);
311        AllServicesModel.init(Sonos,        false);
312        AllPlaylistsModel.init(Sonos, "",   false);
313        MyServicesModel.init(Sonos,         false);
314        alarmsModel.init(Sonos,             false);
315        // launch connection
316        connectSonos();
317
318        // signal UI has finished
319        loadedUI = true;
320
321        // resize main view according to user settings
322        if (!Android) {
323            mainView.width = (settings.widthGU >= minSizeGU ? units.gu(settings.widthGU) : units.gu(minSizeGU));
324            mainView.height = (settings.heightGU >= minSizeGU ? units.gu(settings.heightGU) : units.gu(minSizeGU));
325        }
326    }
327
328    // Show/hide page NoZoneState
329    onNoZoneChanged: {
330        if (noZone) {
331            noZonePage = stackView.push("qrc:/controls2/NoZoneState.qml")
332        } else {
333            if (stackView.currentItem === noZonePage) {
334                stackView.pop()
335            }
336        }
337    }
338
339    // About backend signals
340    // Triggers asynchronous loading on dataUpdated from global models
341    // For these singletons, data loading is processed by backend threads.
342    // Invoking asyncLoad() will schedule the reloading of data.
343    Connections {
344        target: AllZonesModel
345        function onDataUpdated() { AllZonesModel.asyncLoad() }
346        function onLoaded(succeeded) {
347            AllZonesModel.resetModel();
348            reloadZone();
349        }
350    }
351
352    Connections {
353        target: AllServicesModel
354        function onDataUpdated() { AllServicesModel.asyncLoad() }
355        function onLoaded(succeeded) { AllServicesModel.resetModel() }
356    }
357
358    Connections {
359        target: MyServicesModel
360        function onDataUpdated() { MyServicesModel.asyncLoad() }
361        function onLoaded(succeeded) { MyServicesModel.resetModel() }
362    }
363
364    Connections {
365        target: AllFavoritesModel
366        function onDataUpdated() { AllFavoritesModel.asyncLoad() }
367        function onLoaded(succeeded) { AllFavoritesModel.resetModel() }
368        function onCountChanged() { tabs.setProperty(2, "visible", (AllFavoritesModel.count > 0)) }
369    }
370
371    Connections {
372        target: AllArtistsModel
373        function onDataUpdated() { AllArtistsModel.asyncLoad() }
374        function onLoaded(succeeded) { AllArtistsModel.resetModel() }
375    }
376
377    Connections {
378        target: AllAlbumsModel
379        function onDataUpdated() { AllAlbumsModel.asyncLoad() }
380        function onLoaded(succeeded) { AllAlbumsModel.resetModel() }
381    }
382
383    Connections {
384        target: AllGenresModel
385        function onDataUpdated() { AllGenresModel.asyncLoad() }
386        function onLoaded(succeeded) { AllGenresModel.resetModel() }
387    }
388
389    Connections {
390        target: AllComposersModel
391        function onDataUpdated() { AllComposersModel.asyncLoad() }
392        function onLoaded(succeeded) { AllComposersModel.resetModel() }
393    }
394
395    Connections {
396        target: AllPlaylistsModel
397        function onDataUpdated() { AllPlaylistsModel.asyncLoad() }
398        function onLoaded(succeeded) { AllPlaylistsModel.resetModel() }
399    }
400
401    Connections {
402        target: Sonos
403        function onAlarmClockChanged() { alarmsModel.asyncLoad() }
404        function onShareIndexInProgress() {
405            if (!shareIndexInProgress) {
406                shareIndexInProgress = true;
407            }
408        }
409        function onShareIndexFinished() {
410            if (shareIndexInProgress) {
411                shareIndexInProgress = false;
412                // Queue item metadata could be outdated: force reloading of the queue
413                player.trackQueue.loadQueue();
414                // Force reload genres to be sure the items count is uptodate
415                if (!AllGenresModel.isNew()) {
416                    AllGenresModel.asyncLoad();
417                }
418            }
419        }
420    }
421
422    onZoneChanged: {
423        // check for enabled alarm
424        alarmEnabled = isAlarmEnabled();
425    }
426
427    ////////////////////////////////////////////////////////////////////////////
428    ////
429    //// Global actions & helpers
430    ////
431
432    // Find index of a command line argument else -1
433    function indexOfArgument(argv) {
434        for (var i = 0; i < ApplicationArguments.length; ++i) {
435            if (ApplicationArguments[i].indexOf(argv) === 0)
436                return i;
437        }
438        return -1;
439    }
440
441    // Custom debug funtion that's easier to shut off
442    function customdebug(text) {
443        var debug = true; // set to "0" for not debugging
444        //if (args.values.debug) { // *USE LATER*
445        if (debug) {
446            console.info(text);
447        }
448    }
449
450    // ACLS is array as [{type, sn, key, token}]
451    function serializeACLS(acls) {
452        var str = "";
453        for (var i = 0; i < acls.length; ++i) {
454            if (i > 0)
455                str += "|";
456            str += acls[i].type + "," + acls[i].sn + "," + Qt.btoa(acls[i].key) + "," + Qt.btoa(acls[i].token) + "," + Qt.btoa(acls[i].username);
457        }
458        return str;
459    }
460
461    // str format look like 'type0,sn0,key0|type1,sn1,key1'
462    function deserializeACLS(str) {
463        var acls = [];
464        var rows = str.split("|");
465        for (var r = 0; r < rows.length; ++r) {
466            var attrs = rows[r].split(",");
467            if (attrs.length === 4)
468                acls.push({type: attrs[0], sn: attrs[1], key: Qt.atob(attrs[2]), token: Qt.atob(attrs[3]), username: ""});
469            else if (attrs.length === 5)
470                acls.push({type: attrs[0], sn: attrs[1], key: Qt.atob(attrs[2]), token: Qt.atob(attrs[3]), username: Qt.atob(attrs[4])});
471        }
472        return acls;
473    }
474
475    // Try connect to SONOS system
476    function connectSonos() {
477        // if the setting deviceUrl is filled then try it, else continue with the SSDP discovery
478        if (deviceUrl.length > 0) {
479            customdebug("NOTICE: Connecting using the configured URL: " + deviceUrl);
480            ssdp = false; // point out the ssdp discovery isn't used to connect
481            if (Sonos.init(debugLevel, deviceUrl)) {
482                Sonos.renewSubscriptions();
483                return true;
484            }
485            customdebug("ERROR: Connection has failed using the configured URL: " + deviceUrl);
486        }
487        ssdp = true; // point out the ssdp discovery is used to connect
488        var future = Sonos.tryInit(debugLevel);
489        future.finished.connect(function(result){
490            if (result)
491                Sonos.renewSubscriptions();
492            else
493                actionFailed();
494        });
495        return future.start();
496    }
497
498    function reloadZone() {
499        customdebug("Reloading the zone ...");
500        if (connectZone(currentZone)) {
501            // launch the content loader thread
502            Sonos.runLoader();
503            // execute the requested actions at startup
504            if (startup) {
505                startup = false;
506                if (playOnStart) {
507                    player.playStream(inputStreamUrl, "", function(result) {
508                        if (result) {
509                            tabs.pushNowPlaying();
510                        } else {
511                            actionFailed();
512                        }
513                    });
514                }
515            }
516        }
517    }
518
519    signal zoneChanged
520
521    // Try to change zone
522    // On success noZone is set to false
523    function connectZone(name) {
524        customdebug("Connecting zone '" + name + "'");
525        if (AllZonesModel.count === 0) {
526            if (!noZone)
527                noZone = true;
528            return false;
529        }
530        var found = false;
531        var model = null;
532        // search for the zone name
533        for (var p = 0; p < AllZonesModel.count; ++p) {
534            model = AllZonesModel.get(p);
535            if (model.name === name) {
536                found = true;
537                break;
538            }
539        }
540        if (!found) {
541            // search for the coordinator name
542            for (p = 0; p < AllZonesModel.count; ++p) {
543                model = AllZonesModel.get(p);
544                if (model.coordinatorName === currentCoordinator) {
545                    found = true;
546                    break;
547                }
548            }
549        }
550        if (!found) {
551            p = 0; // get the first
552            model = AllZonesModel.get(0);
553        }
554        player.connectZonePlayer(AllZonesModel.holdPlayer(p));
555        currentZone = model.name;
556        currentCoordinator = model.coordinatorName;
557        currentZoneTag = model.shortName;
558        zoneChanged();
559
560        if (noZone)
561            noZone = false;
562        return true;
563    }
564
565    // default action on failure
566    function actionFailed() {
567        popInfo.open(qsTr("Action can't be performed"));
568    }
569
570    function actionFinished(result) {
571        if (!result)
572            actionFailed();
573    }
574
575    // Action on request to update music library
576    function updateMusicIndex() {
577        var future = Sonos.tryRefreshShareIndex();
578        future.finished.connect(function(result) {
579            if (result) {
580                // enable info on loaded index
581                infoLoadedIndex = true;
582                popInfo.open(qsTr("Refreshing of index is running"));
583            } else {
584                actionFailed();
585            }
586        });
587        return future.start();
588    }
589
590    // Action on track clicked
591    function trackClicked(modelItem, play) {
592        play = play === undefined ? true : play  // default play to true
593        var nr = player.trackQueue.model.count + 1; // push back
594        if (play) {
595            return player.playQueue(false, function(result) {
596                if (result) {
597                    player.addItemToQueue(modelItem, nr, function(result) {
598                        var nr = result;
599                        if (nr > 0) {
600                            player.seekTrack(nr, function(result) {
601                                if (result) {
602                                    player.play(function(result) {
603                                        if (result) {
604                                            // Show the Now playing page and make sure the track is visible
605                                            tabs.pushNowPlaying();
606                                        } else {
607                                            actionFailed();
608                                        }
609                                    });
610                                } else {
611                                    actionFailed();
612                                }
613                            });
614                        } else {
615                            actionFailed();
616                        }
617                    });
618                } else {
619                    actionFailed();
620                }
621            });
622        } else {
623            return player.addItemToQueue(modelItem, nr, function(result) {
624                if (result > 0) {
625                    popInfo.open(qsTr("song added"));
626                } else {
627                    actionFailed();
628                }
629            });
630        }
631    }
632
633    // Action on track from queue clicked
634    function indexQueueClicked(index) {
635        if (player.currentIndex === index) {
636            return player.toggle(actionFinished);
637        } else {
638            return player.playQueue(false, function(result) {
639                if (result) {
640                    player.seekTrack(index + 1, function(result) {
641                        if (result) {
642                            player.play(actionFinished);
643                        } else {
644                            actionFailed();
645                        }
646                    });
647                } else {
648                    actionFailed();
649                }
650            });
651        }
652    }
653
654    // Action on shuffle button clicked
655    function shuffleModel(model)
656    {
657        var now = new Date();
658        var seed = now.getSeconds();
659        var index = 0;
660
661        if (model.totalCount !== undefined) {
662            index = Math.floor(model.totalCount * Math.random(seed));
663            while (model.count < index && model.loadMore());
664        }
665        else {
666            index = Math.floor(model.count * Math.random(seed));
667        }
668        if (index >= model.count)
669            return false;
670        else if (player.isPlaying)
671            return trackClicked(model.get(index), false);
672        else
673            return trackClicked(model.get(index), true); // play track
674    }
675
676    // Action add queue multiple items
677    function addMultipleItemsToQueue(modelItemList) {
678        return player.addMultipleItemsToQueue(modelItemList, function(result) {
679            if (result > 0) {
680                popInfo.open(qsTr("%n song(s) added", "", modelItemList.length));
681            } else {
682                actionFailed();
683            }
684        });
685    }
686
687    // Action on play all button clicked
688    function playAll(modelItem)
689    {
690        // replace queue with the bundle item
691        return player.removeAllTracksFromQueue(function(result) {
692            if (result) {
693                player.addItemToQueue(modelItem, 0, function(result) {
694                    var nr = result;
695                    if (nr > 0) {
696                        player.playQueue(false, function(result) {
697                            if (result) {
698                                player.seekTrack(nr, function(result) {
699                                    if (result) {
700                                        player.play(function(result) {
701                                            if (result) {
702                                                // Show the Now playing page and make sure the track is visible
703                                                tabs.pushNowPlaying();
704                                                popInfo.open(qsTr("song added"));
705                                            } else {
706                                                actionFailed();
707                                            }
708                                        });
709                                    } else {
710                                        actionFailed();
711                                    }
712                                });
713                            } else {
714                                actionFailed();
715                            }
716                        });
717                    } else {
718                        actionFailed();
719                    }
720                });
721            } else {
722                actionFailed();
723            }
724        });
725    }
726
727    // Action add queue
728    function addQueue(modelItem)
729    {
730        var nr = player.trackQueue.model.count;
731        return player.addItemToQueue(modelItem, ++nr, function(result) {
732            if (result > 0) {
733                popInfo.open(qsTr("song added"));
734            } else {
735                actionFailed();
736            }
737        });
738    }
739
740    // Action delete all tracks from queue
741    function removeAllTracksFromQueue()
742    {
743        return player.removeAllTracksFromQueue(function(result) {
744            if (result) {
745                popInfo.open(qsTr("Queue cleared"));
746            } else {
747                actionFailed();
748            }
749        });
750    }
751
752    // Action on remove queue track
753    function removeTrackFromQueue(modelItem) {
754        return player.removeTrackFromQueue(modelItem, actionFinished);
755    }
756
757    // Action on move queue item
758    function reorderTrackInQueue(from, to) {
759        if (from < to)
760            ++to;
761        return player.reorderTrackInQueue(from + 1, to + 1, actionFinished);
762    }
763
764    // Action on radio item clicked
765    function radioClicked(modelItem) {
766        return player.playSource(modelItem, function(result) {
767            if (result)
768                tabs.pushNowPlaying();
769            else
770                popInfo.open(qsTr("Action can't be performed"));
771        });
772    }
773
774    // Action on save queue
775    function saveQueue(title) {
776        return player.saveQueue(title, actionFinished);
777    }
778
779    // Action on create playlist
780    function createPlaylist(title) {
781        return player.createSavedQueue(title, actionFinished);
782    }
783
784    // Action on append item to a playlist
785    function addPlaylist(playlistId, modelItem, containerUpdateID) {
786        return player.addItemToSavedQueue(playlistId, modelItem, containerUpdateID, function(result) {
787            if (result > 0) {
788                popInfo.open(qsTr("song added"));
789            } else {
790                actionFailed();
791            }
792        });
793    }
794
795    // Action on remove item from a playlist
796    function removeTracksFromPlaylist(playlistId, selectedIndices, containerUpdateID, onFinished) {
797        return player.removeTracksFromSavedQueue(playlistId, selectedIndices, containerUpdateID, onFinished);
798    }
799
800    // Action on move playlist item
801    function reorderTrackInPlaylist(playlistId, from, to, containerUpdateID, onFinished) {
802        return player.reorderTrackInSavedQueue(playlistId, from, to, containerUpdateID, onFinished);
803    }
804
805    // Action on remove a playlist
806    function removePlaylist(itemId) {
807        var future = Sonos.tryDestroySavedQueue(itemId);
808        future.finished.connect(actionFinished);
809        return future.start();
810    }
811
812    // Action on check item as favorite
813    function addItemToFavorites(modelItem, description, artURI) {
814        var future = Sonos.tryAddItemToFavorites(modelItem.payload, description, artURI);
815        future.finished.connect(actionFinished);
816        return future.start();
817    }
818
819    // Action on uncheck item from favorites
820    function removeFromFavorites(itemPayload) {
821        var id = AllFavoritesModel.findFavorite(itemPayload)
822        if (id.length === 0) // no favorite
823            return true;
824        var future = Sonos.tryDestroyFavorite(id);
825        future.finished.connect(actionFinished);
826        return future.start();
827    }
828
829    // Helpers
830
831    // Converts an duration in ms to a formated string ("minutes:seconds")
832    function durationToString(duration) {
833        var minutes = Math.floor(duration / 1000 / 60);
834        var seconds = Math.floor(duration / 1000) % 60;
835        // Make sure that we never see "NaN:NaN"
836        if (minutes.toString() === 'NaN')
837            minutes = 0;
838        if (seconds.toString() === 'NaN')
839            seconds = 0;
840        return minutes + ":" + (seconds<10 ? "0"+seconds : seconds);
841    }
842
843    function hashValue(str, modulo) {
844        var hash = 0, i, chr, len;
845        if (str.length === 0) return hash;
846        for (i = 0, len = str.length; i < len; i++) {
847          chr   = str.charCodeAt(i);
848          hash  = ((hash << 5) - hash) + chr;
849          hash |= 0; // Convert to 32bit integer
850        }
851        return Math.abs(hash) % modulo;
852    }
853
854    // Make a normalized string from input for filtering
855    function normalizedInput(str) {
856        return Sonos.normalizedInputString(str);
857    }
858
859    // Make container item from model item
860    function makeContainerItem(model) {
861        return {
862            id: model.id,
863            payload: model.payload
864        };
865    }
866
867    function makeArt(art, artist, album) {
868        if (art !== undefined && art !== "")
869            return art;
870        if (album !== undefined && album !== "") {
871            if (thumbValid)
872                return "image://albumart/artist=" + encodeURIComponent(artist) + "&album=" + encodeURIComponent(album);
873            else
874                return "qrc:/images/no_cover.png";
875        } else if (artist !== undefined && artist !== "") {
876            if (thumbValid)
877                return "image://artistart/artist=" + encodeURIComponent(artist);
878            else
879                return "qrc:/images/none.png";
880        }
881        return "qrc:/images/no_cover.png";
882    }
883
884    function makeCoverSource(art, artist, album) {
885        var array = [];
886        if (art !== undefined && art !== "")
887            array.push( {art: art} );
888        if (album !== undefined && album !== "") {
889            if (thumbValid)
890                array.push( {art: "image://albumart/artist=" + encodeURIComponent(artist) + "&album=" + encodeURIComponent(album)} );
891            array.push( {art: "qrc:/images/no_cover.png"} );
892        } else if (artist !== undefined && artist !== "") {
893            if (thumbValid)
894                array.push( {art: "image://artistart/artist=" + encodeURIComponent(artist)} );
895            array.push( {art: "qrc:/images/none.png"} );
896        } else {
897            array.push( {art: "qrc:/images/no_cover.png"} );
898        }
899        return array;
900    }
901
902    function isAlarmEnabled() {
903        var rooms = Sonos.getZoneRooms(player.zoneId);
904        for (var i = 0; i < alarmsModel.count; ++i) {
905            var alarm = alarmsModel.get(i);
906            if (alarm.enabled) {
907                for (var r = 0; r < rooms.length; ++r) {
908                    if (rooms[r]['id'] === alarm.roomId) {
909                        if (alarm.includeLinkedZones || rooms.length === 1)
910                            return true;
911                    }
912                }
913            }
914        }
915        return false;
916    }
917
918    ////////////////////////////////////////////////////////////////////////////
919    ////
920    //// Global keyboard shortcuts
921    ////
922
923    Shortcut {
924        sequences: ["Esc", "Back"]
925        enabled: noZone === false && stackView.depth > 1
926        onActivated: {
927            if (stackView.depth > 1) {
928                if (stackView.currentItem.isRoot)
929                    stackView.pop()
930                else
931                    stackView.currentItem.goUpClicked()
932            }
933        }
934    }
935    Shortcut {
936        sequence: "Menu"
937        onActivated: optionsMenu.open()
938    }
939    Shortcut {
940        sequence: "Alt+Right"           // Alt+Right   Seek forward +10secs
941        onActivated: {
942            var position = player.trackPosition + 10000 < player.trackDuration
943                ? player.trackPosition + 10000 : player.trackDuration;
944            player.seek(position, actionFinished);
945        }
946    }
947    Shortcut {
948        sequence: "Alt+Left"            // Alt+Left    Seek backwards -10secs
949        onActivated: {
950            var position = player.trackPosition - 10000 > 0
951                    ? player.trackPosition - 10000 : 0;
952            player.seek(position, actionFinished);
953        }
954    }
955    Shortcut {
956        sequence: "Ctrl+Left"           // Ctrl+Left   Previous Song
957        onActivated: {
958            player.previousSong(actionFinished);
959        }
960    }
961    Shortcut {
962        sequence: "Ctrl+Right"          // Ctrl+Right  Next Song
963        onActivated: {
964            player.nextSong(actionFinished);
965        }
966    }
967    Shortcut {
968        sequence: "Ctrl+Up"             // Ctrl+Up     Volume up
969        onActivated: {
970            var v = player.volumeMaster + 5 > 100 ? 100 : player.volumeMaster + 5;
971            player.setVolumeGroup(v, function(result) {
972                if (result) {
973                    player.volumeMaster = Math.round(v);
974                } else {
975                    actionFailed();
976                }
977            });
978        }
979    }
980    Shortcut {
981        sequence: "Ctrl+Down"           // Ctrl+Down   Volume down
982        onActivated: {
983            var v = player.volumeMaster - 5 < 0 ? 0 : player.volumeMaster - 5;
984            player.setVolumeGroup(v, function(result) {
985                if (result) {
986                    player.volumeMaster = Math.round(v);
987                } else {
988                    actionFailed();
989                }
990            });
991        }
992    }
993    Shortcut {
994        sequence: "Ctrl+R"              // Ctrl+R       Repeat toggle
995        onActivated: {
996            var old = player.repeat
997            player.toggleRepeat(function(result) {
998                if (result) {
999                    player.repeat = !old;
1000                } else {
1001                    actionFailed();
1002                }
1003            });
1004        }
1005    }
1006    Shortcut {
1007        sequence: "Ctrl+F"              // Ctrl+F      Show Search popup
1008        onActivated: {
1009            stackView.currentItem.searchClicked()
1010        }
1011    }
1012    Shortcut {
1013        sequence: "Ctrl+J"              // Ctrl+J      Jump to playing song
1014        onActivated: {
1015            tabs.pushNowPlaying();
1016            if (nowPlayingPage !== null)
1017                nowPlayingPage.isListView = true;
1018        }
1019    }
1020    Shortcut {
1021        sequence: "Ctrl+N"              // Ctrl+N      Show Now playing
1022        onActivated: {
1023            tabs.pushNowPlaying()
1024        }
1025    }
1026    Shortcut {
1027        sequence: "Ctrl+P"              // Ctrl+P      Toggle playing state
1028        onActivated: {
1029            player.toggle(actionFinished);
1030        }
1031    }
1032    Shortcut {
1033        sequence: "Ctrl+Q"              // Ctrl+Q      Quit the app
1034        onActivated: {
1035            Qt.quit();
1036        }
1037    }
1038    Shortcut {
1039        sequence: "Ctrl+U"              // Ctrl+U      Shuffle toggle
1040        onActivated: {
1041            var old = player.shuffle
1042            player.toggleShuffle(function(result) {
1043                if (result) {
1044                    player.shuffle = !old;
1045                } else {
1046                    actionFailed();
1047                }
1048            });
1049        }
1050    }
1051    Shortcut {
1052        sequence: "P"                   // P          Toggle playing state
1053        onActivated: {
1054            player.toggle(actionFinished);
1055        }
1056    }
1057    Shortcut {
1058        sequence: "B"                   // B          Previous Song
1059        onActivated: {
1060            player.previousSong(true);
1061        }
1062    }
1063    Shortcut {
1064        sequence: "N"                   // N          Next Song
1065        onActivated: {
1066            player.nextSong(true, true);
1067        }
1068    }
1069
1070
1071    ////////////////////////////////////////////////////////////////////////////
1072    ////
1073    //// Application main view
1074    ////
1075
1076    property bool alarmEnabled: false
1077    property bool shareIndexInProgress: false
1078
1079    header: ToolBar {
1080        id: mainToolBar
1081        Material.foreground: styleMusic.view.foregroundColor
1082        Material.background: styleMusic.view.backgroundColor
1083
1084        state: "default"
1085        states: [
1086            State {
1087                name: "default"
1088            }
1089        ]
1090
1091        RowLayout {
1092            spacing: 0
1093            anchors.fill: parent
1094
1095            Item {
1096                width: units.gu(6)
1097                height: width
1098
1099                Icon {
1100                    width: units.gu(5)
1101                    height: width
1102                    anchors.centerIn: parent
1103                    source: {
1104                        if (mainView.noZone) {
1105                            "qrc:/images/info.svg"
1106                        } else if (stackView.depth > 1) {
1107                            if (stackView.currentItem.isRoot)
1108                                "qrc:/images/go-previous.svg"
1109                            else
1110                                "qrc:/images/go-up.svg"
1111                        } else {
1112                            "qrc:/images/navigation-menu.svg"
1113                        }
1114                    }
1115
1116                    onClicked: {
1117                        if (stackView.depth > 1) {
1118                            if (stackView.currentItem.isRoot)
1119                                stackView.pop()
1120                            else
1121                                stackView.currentItem.goUpClicked()
1122                        } else {
1123                            drawer.open()
1124                        }
1125                    }
1126
1127                    visible: !mainView.noZone
1128                    enabled: !mainView.jobRunning
1129                }
1130            }
1131
1132            Label {
1133                id: titleLabel
1134                text: stackView.currentItem != null ? stackView.currentItem.pageTitle : ""
1135                font.pointSize: units.fs("x-large")
1136                elide: Label.ElideRight
1137                horizontalAlignment: Qt.AlignHCenter
1138                verticalAlignment: Qt.AlignVCenter
1139                Layout.fillWidth: true
1140
1141                /* Show more info */
1142                Icon {
1143                    id: iconInfo
1144                    color: "#e95420"
1145                    width: units.gu(5)
1146                    height: width
1147                    anchors.verticalCenter: parent.verticalCenter
1148                    anchors.right: parent.right
1149                    source: shareIndexInProgress ? "qrc:/images/download.svg" : player.sleepTimerEnabled ? "qrc:/images/timer.svg" : "qrc:/images/alarm.svg"
1150                    visible: player.sleepTimerEnabled || alarmEnabled || shareIndexInProgress
1151                    enabled: visible
1152                    animationRunning: shareIndexInProgress
1153                }
1154            }
1155
1156            Item {
1157                width: units.gu(6)
1158                height: width
1159
1160                Icon {
1161                    width: units.gu(5)
1162                    height: width
1163                    anchors.centerIn: parent
1164                    source: "qrc:/images/home.svg"
1165
1166                    onClicked: {
1167                        stackView.pop()
1168                    }
1169
1170                    visible: (stackView.currentItem !== null && !stackView.currentItem.isRoot)
1171                    enabled: visible
1172                }
1173
1174                Icon {
1175                    width: units.gu(5)
1176                    height: width
1177                    anchors.centerIn: parent
1178                    source: "qrc:/images/contextual-menu.svg"
1179
1180                    visible: (stackView.currentItem === null || stackView.currentItem.isRoot)
1181                    enabled: visible
1182
1183                    onClicked: optionsMenu.open()
1184
1185                    Menu {
1186                        id: optionsMenu
1187                        x: parent.width - width
1188                        transformOrigin: Menu.TopRight
1189
1190                        MenuItem {
1191                            visible: !noZone
1192                            height: (visible ? implicitHeight : 0)
1193                            text: qsTr("Standby timer")
1194                            font.pointSize: units.fs("medium")
1195                            onTriggered: dialogSleepTimer.open()
1196                        }
1197                        MenuItem {
1198                            visible: !noZone
1199                            height: (visible ? implicitHeight : 0)
1200                            text: qsTr("Sonos settings")
1201                            font.pointSize: units.fs("medium")
1202                            onTriggered: dialogSonosSettings.open()
1203                        }
1204                        MenuItem {
1205                            text: qsTr("General settings")
1206                            font.pointSize: units.fs("medium")
1207                            onTriggered: dialogApplicationSettings.open()
1208                        }
1209                        MenuItem {
1210                            text: qsTr("About")
1211                            font.pointSize: units.fs("medium")
1212                            onTriggered: dialogAbout.open()
1213                        }
1214                    }
1215                }
1216            }
1217        }
1218    }
1219
1220    ListModel {
1221        id: tabs
1222        ListElement { title: qsTr("My Services"); source: "qrc:/controls2/MusicServices.qml"; visible: true }
1223        ListElement { title: qsTr("My Index"); source: "qrc:/controls2/Index.qml"; visible: true }
1224        ListElement { title: qsTr("Favorites"); source: "qrc:/controls2/Favorites.qml"; visible: false }
1225        ListElement { title: qsTr("Playlists"); source: "qrc:/controls2/Playlists.qml"; visible: true }
1226        ListElement { title: qsTr("Alarm clock"); source: "qrc:/controls2/Alarms.qml"; visible: true }
1227        ListElement { title: qsTr("This Device"); source: "qrc:/controls2/ThisDevice.qml"; visible: true }
1228
1229        function initialIndex() {
1230            return (settings.tabIndex === -1 ? 0
1231            : settings.tabIndex > tabs.count - 1 ? tabs.count - 1
1232            : settings.tabIndex);
1233        }
1234
1235        function pushNowPlaying()
1236        {
1237            if (!wideAspect) {
1238                if (nowPlayingPage === null)
1239                    nowPlayingPage = stackView.push("qrc:/controls2/NowPlaying.qml", false, true);
1240                if (nowPlayingPage.isListView) {
1241                    nowPlayingPage.isListView = false; // ensure full view
1242                }
1243            }
1244        }
1245    }
1246
1247    Drawer {
1248        id: drawer
1249        width: Math.min(mainView.width, mainView.height) / 2
1250        height: mainView.height
1251        interactive: stackView.depth === 1
1252
1253        property alias currentIndex: pageList.currentIndex
1254
1255        Component.onCompleted: {
1256            currentIndex = tabs.initialIndex();
1257            stackView.clear();
1258            stackView.push(pageList.model.get(currentIndex).source);
1259        }
1260
1261        ListView {
1262            id: pageList
1263
1264            focus: true
1265            currentIndex: -1
1266            anchors.fill: parent
1267
1268            delegate: ItemDelegate {
1269                visible: model.visible
1270                height: model.visible ? implicitHeight : 0
1271                width: parent.width
1272                text: model.title
1273                font.pointSize: units.fs("large")
1274                highlighted: ListView.isCurrentItem
1275                onClicked: {
1276                    if (index != pageList.currentIndex) {
1277                        stackView.clear(StackView.ReplaceTransition);
1278                        stackView.push(model.source);
1279                        pageList.currentIndex = index;
1280                        settings.tabIndex = index;
1281                    }
1282                    drawer.close()
1283                }
1284            }
1285
1286            model: tabs
1287
1288            ScrollIndicator.vertical: ScrollIndicator { }
1289        }
1290    }
1291
1292    property alias stackView: stackView
1293
1294    StackView {
1295        id: stackView
1296        anchors {
1297            bottom: musicToolbar.top
1298            fill: undefined
1299            left: parent.left
1300            right: nowPlayingSidebarLoader.left
1301            top: parent.top
1302        }
1303        initialItem: "qrc:/controls2/Welcome.qml"
1304    }
1305
1306
1307    DialogApplicationSettings {
1308        id: dialogApplicationSettings
1309    }
1310
1311    DialogAbout {
1312        id: dialogAbout
1313    }
1314
1315    DialogManageQueue {
1316        id: dialogManageQueue
1317    }
1318
1319    DialogSelectSource {
1320        id: dialogSelectSource
1321    }
1322
1323    DialogSleepTimer {
1324        id: dialogSleepTimer
1325    }
1326
1327    DialogSongInfo {
1328        id: dialogSongInfo
1329    }
1330
1331    DialogSoundSettings {
1332        id: dialogSoundSettings
1333    }
1334
1335    property alias musicToolbar: musicToolbar
1336
1337    Loader {
1338        id: musicToolbar
1339        active: true
1340        height: units.gu(7.25)
1341        anchors { // start offscreen
1342            left: parent.left
1343            right: parent.right
1344            top: parent.bottom
1345            topMargin: shown ? -height : 0
1346        }
1347        asynchronous: true
1348        source: "qrc:/controls2/components/MusicToolbar.qml"
1349
1350        property bool shown: status === Loader.Ready && !noZone && (!wideAspect || player.currentMetaSource === "") &&
1351                             (stackView.currentItem && (stackView.currentItem.showToolbar === undefined || stackView.currentItem.showToolbar))
1352
1353    }
1354
1355    Loader {
1356        id: nowPlayingSidebarLoader
1357        active: true
1358        width: units.gu(44)
1359        anchors {  // start offscreen
1360            bottom: parent.bottom
1361            left: parent.right
1362            top: parent.top
1363        }
1364        asynchronous: true
1365        source: "qrc:/controls2/components/NowPlayingSidebar.qml"
1366        anchors.leftMargin: shown ? -width : 0
1367
1368        property bool shown: status === Loader.Ready && !noZone && loadedUI && wideAspect && player.currentMetaSource !== ""
1369
1370        onShownChanged: {
1371            // move to current position in queue
1372            if (shown)
1373                item.ensureListViewLoaded();
1374        }
1375
1376        Behavior on anchors.leftMargin {
1377            NumberAnimation {
1378            }
1379        }
1380    }
1381
1382    property bool jobRunning: false
1383
1384    ActivitySpinner {
1385        id: spinner
1386        visible: jobRunning
1387    }
1388}
1389