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 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 onJobCountChanged: jobRunning = Sonos.jobCount > 0 ? true : false 238 239 onInitDone: { 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 onLoadingFinished: { 255 if (infoLoadedIndex) { 256 infoLoadedIndex = false; 257 popInfo.open(qsTr("Index loaded")); 258 } 259 } 260 261 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 onDataUpdated: AllZonesModel.asyncLoad() 346 onLoaded: { 347 AllZonesModel.resetModel(); 348 reloadZone(); 349 } 350 } 351 352 Connections { 353 target: AllServicesModel 354 onDataUpdated: AllServicesModel.asyncLoad() 355 onLoaded: AllServicesModel.resetModel() 356 } 357 358 Connections { 359 target: MyServicesModel 360 onDataUpdated: MyServicesModel.asyncLoad() 361 onLoaded: MyServicesModel.resetModel() 362 } 363 364 Connections { 365 target: AllFavoritesModel 366 onDataUpdated: AllFavoritesModel.asyncLoad() 367 onLoaded: AllFavoritesModel.resetModel() 368 onCountChanged: { tabs.setProperty(2, "visible", (AllFavoritesModel.count > 0)) } 369 } 370 371 Connections { 372 target: AllArtistsModel 373 onDataUpdated: AllArtistsModel.asyncLoad() 374 onLoaded: AllArtistsModel.resetModel() 375 } 376 377 Connections { 378 target: AllAlbumsModel 379 onDataUpdated: AllAlbumsModel.asyncLoad() 380 onLoaded: AllAlbumsModel.resetModel() 381 } 382 383 Connections { 384 target: AllGenresModel 385 onDataUpdated: AllGenresModel.asyncLoad() 386 onLoaded: AllGenresModel.resetModel() 387 } 388 389 Connections { 390 target: AllComposersModel 391 onDataUpdated: AllComposersModel.asyncLoad() 392 onLoaded: AllComposersModel.resetModel() 393 } 394 395 Connections { 396 target: AllPlaylistsModel 397 onDataUpdated: AllPlaylistsModel.asyncLoad() 398 onLoaded: AllPlaylistsModel.resetModel() 399 } 400 401 Connections { 402 target: Sonos 403 onAlarmClockChanged: alarmsModel.asyncLoad() 404 onShareIndexInProgress: { 405 if (!shareIndexInProgress) { 406 shareIndexInProgress = true; 407 } 408 } 409 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