diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecd0c051..6262f6109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [Unreleased] + +### Added +- Dashboard widget for Nextcloud + [#1172](https://github.com/owncloud/music/pull/1172) + +### Changed +- Ampache API: + * Action `get_indexes` supports also `type=song_artist` + * Added fields `art` and `has_art` to the `podcast_episode` and `live_stream` result types + * For radio stations without user-supplied name, use the stream URL as a name + +### Fixed +- Ampache API: + * Action `playlist_songs` returning internal error 500 if the playlist contains any broken track references +- Song progress shown incorrectly in the media session integration of Chrome when playing (exotic file types) with the fallback Aurora.js player + ## 2.0.1 - 2024-09-08 ### Added diff --git a/css/dashboard/dashboard-music-icons.css b/css/dashboard/dashboard-music-icons.css new file mode 100644 index 000000000..482e794c0 --- /dev/null +++ b/css/dashboard/dashboard-music-icons.css @@ -0,0 +1,43 @@ + +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Pauli Järvinen + * @copyright Pauli Järvinen 2024 + */ + +.icon-music-app { + background-image: url(../../img/music-dark.svg); + filter: var(--background-invert-if-dark); +} + +.music-widget .icon-play { + background-image: url(../../img/play-big.svg); +} + +.music-widget .icon-pause { + background-image: url(../../img/pause-big.svg); +} + +.music-widget .icon-stop { + background-image: url(../../img/stop.svg); +} + +.music-widget .icon-skip-prev { + background-image: url(../../img/skip-previous.svg); +} + +.music-widget .icon-skip-next { + background-image: url(../../img/skip-next.svg); +} + +.music-widget .icon-shuffle { + background-image: url(../../img/shuffle.svg); +} + +.music-widget .icon-repeat { + background-image: url(../../img/repeat.svg); +} diff --git a/css/dashboard/dashboard-music-widget.css b/css/dashboard/dashboard-music-widget.css new file mode 100644 index 000000000..b065d181f --- /dev/null +++ b/css/dashboard/dashboard-music-widget.css @@ -0,0 +1,160 @@ +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Pauli Järvinen + * @copyright Pauli Järvinen 2024 + */ + +.music-widget { + height: 100%; + display: flex; + flex-flow: column; +} + +.music-widget .select-container { + flex: 0 1 auto; +} + +.music-widget .tracks-container { + flex: 1 1 auto; + overflow-y: scroll; + scrollbar-width: thin; + margin-top: 8px; + margin-bottom: 8px; +} + +.music-widget .progress-and-order { + flex: 0 0 25px; + display: flex; + flex-flow: row; +} + +.music-widget .progress-and-order .control { + flex: 0 0 40px; + margin-top: -10px; +} + +.music-widget .progress-and-order .music-progress-info { + flex: 1 1 auto; + position: relative; +} + +.music-widget .progress-and-order .music-progress-info span { + line-height: unset; +} + +.music-widget .progress-and-order .music-progress-info .progress-text { + line-height: 100%; + position: absolute; + color: black; + top: 0; + bottom: auto; + left: 0; + right: 0; + z-index: 1; + pointer-events: none; +} + +.music-widget .current-song-label { + flex: 0 0 25px; + margin-left: 8px; + margin-right: 8px; + margin-top: -5px; + text-align: center; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.music-widget .player-controls { + flex: 0 0 50px; + display: flex; + flex-flow: row; +} + +.music-widget .player-controls .albumart { + width: 50px; + height: 50px; + border: 1px solid var(--color-text-lighter); + margin-left: 8px; + margin-right: 16px; + background-size: contain; + cursor: pointer; +} + +.music-widget .player-controls .control { + background-size: contain; + margin: 9px; +} + +.music-widget .player-controls .playback.control { + width: 32px; + height: 32px; +} + +.music-widget .player-controls .music-volume-control { + position: relative; +} + +.music-widget .player-controls .music-volume-control .volume-icon { + left: 10px; + top: 4px; +} + +.music-widget .player-controls .music-volume-control .volume-slider { + width: 50px; + top: 24px; + left: 30px +} + +.music-widget select { + width: 100%; +} + +.music-widget select:invalid { + color: var(--color-text-lighter); +} + +.music-widget select option { + color: var(--color-main-text); +} + +.music-widget li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 12px; + padding-right: 12px; + line-height: 28px; + border-radius: var(--border-radius-large); + cursor: pointer; +} + +.music-widget li * { + cursor: pointer; +} + +.music-widget li:hover, +.music-widget li.current { + transition: background-color 0.3s ease; + background-color: var(--color-background-hover); +} + +.music-widget .dimmed { + opacity: .5; +} + +.music-widget .control { + cursor: pointer; + opacity: .5; + filter: var(--background-invert-if-dark); +} + +.music-widget .control:hover, +.music-widget .control.toggle.active { + opacity: 1; +} diff --git a/css/embedded/files-music-mobile-tablet.css b/css/embedded/files-music-mobile-tablet.css index 4f42889d9..90dd46393 100644 --- a/css/embedded/files-music-mobile-tablet.css +++ b/css/embedded/files-music-mobile-tablet.css @@ -38,7 +38,7 @@ left: 160px; right: 50px; } -#music-controls.mobile .volume-control { +#music-controls.mobile .music-volume-control { display: none; } @@ -46,7 +46,7 @@ #music-controls.extra-narrow #song-info { width: 100%; } -#music-controls.extra-narrow .progress-info { +#music-controls.extra-narrow .music-progress-info { display: none; } diff --git a/css/embedded/files-music-player.css b/css/embedded/files-music-player.css index caf8a93a5..8dae773d2 100644 --- a/css/embedded/files-music-player.css +++ b/css/embedded/files-music-player.css @@ -182,46 +182,12 @@ line-height: 29px; } -#music-controls .progress-info { +#music-controls .music-progress-info { width: 45%; - text-align: center; margin: 0 auto 25px auto; - overflow: hidden; -} - -#music-controls .progress-info span { - line-height: 30px; } -#music-controls .seek-bar { - width: 100%; - height: 15px; - margin: 0 auto 0 auto; - position: relative; - background-color: #eee; -} - -#music-controls .seek-bar, #music-controls .play-bar, #music-controls .buffer-bar { - display: block; -} - -#music-controls .play-bar, #music-controls .buffer-bar { +#music-controls .music-volume-control { position: absolute; - left: 0; - top: 0; - height: 15px; - width: 0%; - background-color: var(--color-primary, #1d2d44); -} - -.ie #music-controls .play-bar, #music-controls .buffer-bar { - background-color: #1d2d44; -} - -#music-controls .translucent { - opacity: 0.75; -} - -#music-controls .buffer-bar { - opacity: 0.1; + right: 120px; } diff --git a/css/shared/music-progress-info.css b/css/shared/music-progress-info.css new file mode 100644 index 000000000..e97679d83 --- /dev/null +++ b/css/shared/music-progress-info.css @@ -0,0 +1,51 @@ +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Pauli Järvinen + * @copyright Pauli Järvinen 2024 + */ + +.music-progress-info { + text-align: center; + overflow: hidden; +} + +.music-progress-info span { + line-height: 30px; +} + +.music-progress-info .seek-bar { + width: 100%; + height: 15px; + margin: 0 auto 0 auto; + position: relative; + background-color: #eee; +} + +.music-progress-info .seek-bar, .music-progress-info .play-bar, .music-progress-info .buffer-bar { + display: block; +} + +.music-progress-info .play-bar, .music-progress-info .buffer-bar { + position: absolute; + left: 0; + top: 0; + height: 15px; + width: 0%; + background-color: var(--color-primary, #1d2d44); +} + +.ie .music-progress-info .play-bar, .ie .music-progress-info .buffer-bar { + background-color: #1d2d44; +} + +.music-progress-info .translucent { + opacity: 0.75; +} + +.music-progress-info .buffer-bar { + opacity: 0.1; +} diff --git a/css/embedded/files-music-volume-control.css b/css/shared/music-volume-control.css similarity index 64% rename from css/embedded/files-music-volume-control.css rename to css/shared/music-volume-control.css index deac1b75b..fb27fb223 100644 --- a/css/embedded/files-music-volume-control.css +++ b/css/shared/music-volume-control.css @@ -5,25 +5,16 @@ * later. See the COPYING file. * * @author Pauli Järvinen - * @copyright Pauli Järvinen 2023 + * @copyright Pauli Järvinen 2023, 2024 */ -#music-controls .volume-control { - position: absolute; - right: 120px; -} - -.ie.lte9 #music-controls .volume-control { - display: none; -} - -#music-controls #volume-icon { +.music-volume-control .volume-icon { position: absolute; top: 0; left: 0; } -#music-controls .volume-control input[type=range] { +.music-volume-control .volume-slider { position: absolute; width: 58px; height: 3px; @@ -36,14 +27,15 @@ transform: rotate(270deg); } -.ie #music-controls .volume-control input[type=range] { + +.ie .music-volume-control .volume-slider { height:auto; top: 3px; left: 26px; background-color: transparent; } -::-webkit-slider-thumb { +.music-volume-control .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; background-color: #666; border-radius: 100%; @@ -51,11 +43,11 @@ height: 10px; } -:hover::-webkit-slider-thumb { +.music-volume-control .volume-slider:hover::-webkit-slider-thumb { cursor: pointer; } -::-moz-range-thumb { +.music-volume-control .volume-slider::-moz-range-thumb { -moz-appearance: none; background-color: #666; border-radius: 100%; @@ -63,6 +55,6 @@ height: 10px; } -:hover::-moz-range-thumb { +.music-volume-control .volume-slider:hover::-moz-range-thumb { cursor: pointer; } diff --git a/js/app/controllers/maincontroller.js b/js/app/controllers/maincontroller.js index c0e4438dc..e21a9853d 100644 --- a/js/app/controllers/maincontroller.js +++ b/js/app/controllers/maincontroller.js @@ -12,9 +12,9 @@ angular.module('Music').controller('MainController', [ '$rootScope', '$scope', '$timeout', '$window', 'ArtistFactory', -'playlistService', 'libraryService', 'inViewService', 'gettextCatalog', 'Restangular', +'playQueueService', 'libraryService', 'inViewService', 'gettextCatalog', 'Restangular', function ($rootScope, $scope, $timeout, $window, ArtistFactory, - playlistService, libraryService, inViewService, gettextCatalog, Restangular) { + playQueueService, libraryService, inViewService, gettextCatalog, Restangular) { // retrieve language from backend - is set in ng-app HTML element gettextCatalog.currentLanguage = $rootScope.lang; @@ -34,17 +34,17 @@ function ($rootScope, $scope, $timeout, $window, ArtistFactory, $rootScope.playing = false; $rootScope.playingView = null; $scope.currentTrack = null; - playlistService.subscribe('trackChanged', function(e, listEntry) { + playQueueService.subscribe('trackChanged', function(listEntry) { $scope.currentTrack = listEntry.track; - $scope.currentTrackIndex = playlistService.getCurrentIndex(); + $scope.currentTrackIndex = playQueueService.getCurrentIndex(); }); - playlistService.subscribe('play', function(e, playingView) { + playQueueService.subscribe('play', function(playingView) { // assume that the play started from current view if no other view given $rootScope.playingView = playingView || $rootScope.currentView; }); - playlistService.subscribe('playlistEnded', function() { + playQueueService.subscribe('playlistEnded', function() { $rootScope.playingView = null; $scope.currentTrack = null; $scope.currentTrackIndex = -1; @@ -311,7 +311,7 @@ function ($rootScope, $scope, $timeout, $window, ArtistFactory, filesToScan = null; filesToScanIterator = 0; previouslyScannedCount = 0; - // Genre and artist IDs have got invalidated while resetting the libarary, drop any related filters + // Genre and artist IDs have got invalidated while resetting the library, drop any related filters if ($scope.smartListParams !== null) { $scope.smartListParams.genres = []; $scope.smartListParams.artists = []; diff --git a/js/app/controllers/navigationcontroller.js b/js/app/controllers/navigationcontroller.js index 2946798ce..6e0c16469 100644 --- a/js/app/controllers/navigationcontroller.js +++ b/js/app/controllers/navigationcontroller.js @@ -13,9 +13,9 @@ angular.module('Music').controller('NavigationController', [ '$rootScope', '$scope', '$document', 'Restangular', '$timeout', '$location', - 'playlistService', 'playlistFileService', 'podcastService', 'libraryService', 'gettextCatalog', + 'playQueueService', 'playlistFileService', 'podcastService', 'libraryService', 'gettextCatalog', function ($rootScope, $scope, $document, Restangular, $timeout, $location, - playlistService, playlistFileService, podcastService, libraryService, gettextCatalog) { + playQueueService, playlistFileService, podcastService, libraryService, gettextCatalog) { $rootScope.loading = true; @@ -176,7 +176,7 @@ angular.module('Music').controller('NavigationController', [ if (confirmed) { Restangular.one('playlists', playlist.id).remove(); - // remove the elemnt also from the AngularJS list + // remove the element also from the AngularJS list libraryService.removePlaylist(playlist); } }, @@ -262,13 +262,13 @@ angular.module('Music').controller('NavigationController', [ // Play/pause playlist $scope.togglePlay = function(destination, playlist) { if ($rootScope.playingView == destination) { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } else { let play = function(id, tracks) { if (tracks && tracks.length) { - playlistService.setPlaylist(id, tracks); - playlistService.publish('play', destination); + playQueueService.setPlaylist(id, tracks); + playQueueService.publish('play', destination); } }; @@ -442,7 +442,7 @@ angular.module('Music').controller('NavigationController', [ let newTracks = _.map(trackIds, function(trackId) { return { track: libraryService.getTrack(trackId) }; }); - playlistService.onTracksAdded(newTracks); + playQueueService.onTracksAdded(newTracks); } Restangular.one('playlists', playlist.id).all('add').post({trackIds: trackIds.join(',')}).then(function (result) { @@ -455,7 +455,7 @@ angular.module('Music').controller('NavigationController', [ // Update the currently playing list if necessary if ($rootScope.playingView == '#/playlist/' + playlist.id) { let playingIndex = _.findIndex(playlist.tracks, { track: $scope.currentTrack }); - playlistService.onPlaylistModified(playlist.tracks, playingIndex); + playQueueService.onPlaylistModified(playlist.tracks, playingIndex); } let trackIds = _.map(playlist.tracks, 'track.id'); diff --git a/js/app/controllers/playercontroller.js b/js/app/controllers/playercontroller.js index 36e7a3f58..6797bd811 100644 --- a/js/app/controllers/playercontroller.js +++ b/js/app/controllers/playercontroller.js @@ -7,14 +7,15 @@ * @author Morris Jobke * @author Pauli Järvinen * @copyright Morris Jobke 2013 - * @copyright Pauli Järvinen 2017 - 2023 + * @copyright Pauli Järvinen 2017 - 2024 */ import radioIconPath from '../../../img/radio-file.svg'; +import { BrowserMediaSession } from 'shared/browsermediasession'; angular.module('Music').controller('PlayerController', [ -'$scope', '$rootScope', 'playlistService', 'Audio', 'gettextCatalog', 'Restangular', '$timeout', '$q', '$document', '$location', -function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangular, $timeout, $q, $document, $location) { +'$scope', '$rootScope', 'playQueueService', 'Audio', 'gettextCatalog', 'Restangular', '$timeout', '$q', '$document', '$location', +function ($scope, $rootScope, playQueueService, Audio, gettextCatalog, Restangular, $timeout, $q, $document, $location) { $scope.loading = false; $scope.shiftHeldDown = false; @@ -40,6 +41,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula let scrobblePending = false; let scheduledRadioTitleFetch = null; let abortRadioTitleFetch = null; + let browserMediaSession = new BrowserMediaSession($scope.player); const GAPLESS_PLAY_OVERLAP_MS = 500; const RADIO_INFO_POLL_PERIOD_MS = 30000; const RADIO_INFO_POLL_MAX_ATTEMPTS = 3; @@ -62,8 +64,8 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.repeat = val; } - playlistService.setRepeat($scope.repeat !== 'false'); // the "repeat-one" is handled internally by the PlayerController - playlistService.setShuffle($scope.shuffle); + playQueueService.setRepeat($scope.repeat !== 'false'); // the "repeat-one" is handled internally by the PlayerController + playQueueService.setShuffle($scope.shuffle); // Player events may fire synchronously or asynchronously. Utilize $timeout // to always handle them asynchronously to run the handler within digest loop @@ -84,7 +86,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula // prepare the next song once buffering this one is done (sometimes the percent never goes above something like 99.996%) if (percent > 99 && $scope.currentTrack.type === 'song') { - let entry = playlistService.peekNextTrack(); + let entry = playQueueService.peekNextTrack(); if (entry?.track?.id !== undefined) { const {mime, url} = getPlayableFileUrl(entry.track) || [null, null]; if (mime !== null && url !== null) { @@ -110,7 +112,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula if ($scope.position.total > 0 && $scope.currentTrack.type === 'song' && $scope.repeat !== 'one') { let timeLeft = $scope.position.total*1000 - currentTime; if (timeLeft < GAPLESS_PLAY_OVERLAP_MS) { - let nextTrackId = playlistService.peekNextTrack()?.track?.id; + let nextTrackId = playQueueService.peekNextTrack()?.track?.id; if (nextTrackId !== null && nextTrackId !== $scope.currentTrack.id) { onEnd(); } @@ -150,7 +152,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula }); function onEnd() { - // Srcrobble now if it hasn't happened before reaching the end of the track + // Scrobble now if it hasn't happened before reaching the end of the track if (scrobblePending) { scrobbleCurrentTrack(); } @@ -270,7 +272,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.stop(); } - // After restoring the previous session upon brwoser restart, at least Firefox sometimes leaves + // After restoring the previous session upon browser restart, at least Firefox sometimes leaves // the shift state as "held". To work around this, reset the state whenever the current track changes. $scope.shiftHeldDown = false; } @@ -396,7 +398,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.toggleShuffle = function() { $scope.shuffle = !$scope.shuffle; - playlistService.setShuffle($scope.shuffle); + playQueueService.setShuffle($scope.shuffle); OCA.Music.Storage.set('shuffle', $scope.shuffle.toString()); }; @@ -407,7 +409,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula 'one' : 'false' }; $scope.repeat = nextState[$scope.repeat]; - playlistService.setRepeat($scope.repeat !== 'false'); // the "repeat-one" is handled internally by the PlayerController + playQueueService.setRepeat($scope.repeat !== 'false'); // the "repeat-one" is handled internally by the PlayerController OCA.Music.Storage.set('repeat', $scope.repeat); }; @@ -451,7 +453,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.currentTrack = null; $rootScope.playing = false; $rootScope.started = false; - playlistService.clearPlaylist(); + playQueueService.clearPlaylist(); }; $scope.stepPlaybackRate = function($event, decrease, rollover) { @@ -498,7 +500,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $timeout(() => $scope.playPauseContextMenuVisible = true); }; - // Show context menu on right click of play/pause button, surpress the browser context menu + // Show context menu on right click of play/pause button, suppress the browser context menu $scope.playbackBtnContextMenu = function($event) { $event.preventDefault(); $timeout(() => $scope.playPauseContextMenuVisible = true); @@ -509,7 +511,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula }); $scope.next = function(startOffset = 0, gapless = false) { - let entry = playlistService.jumpToNextTrack(); + let entry = playQueueService.jumpToNextTrack(); // For ordinary tracks, skip the tracks with unsupported MIME types. // For external streams, we don't know the MIME type, and we just assume that they can be played. @@ -521,7 +523,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula while (entry !== null && !getPlayableFileUrl(entry.track)) { tracksSkipped = true; startOffset = null; // offset is not meaningful if we couldn't play the requested track - entry = playlistService.jumpToNextTrack(); + entry = playQueueService.jumpToNextTrack(); } if (tracksSkipped) { OC.Notification.showTemporary(gettextCatalog.getString('Some not playable tracks were skipped.')); @@ -533,13 +535,13 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.prev = function() { // Jump to the beginning of the current track if it has already played more than 2 secs. - // This is disalbed for radio streams where jumping to the beginning often does not work. + // This is disabled for radio streams where jumping to the beginning often does not work. if ($scope.position.current > 2.0 && $scope.currentTrack?.type !== 'radio') { $scope.player.seek(0); } // Jump to the previous track if the current track has played only 2 secs or less else { - let track = playlistService.jumpToPrevTrack(); + let track = playQueueService.jumpToPrevTrack(); if (track !== null) { setCurrentTrack(track); } @@ -612,11 +614,11 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula $scope.seekForward = $scope.player.seekForward; - playlistService.subscribe('play', function(_event, _playingView = null, startOffset = 0) { + playQueueService.subscribe('play', function(_playingView = null, startOffset = 0) { $scope.next(startOffset); /* fetch track and start playing*/ }); - playlistService.subscribe('togglePlayback', $scope.togglePlayback); + playQueueService.subscribe('togglePlayback', $scope.togglePlayback); $scope.scrollToCurrentTrack = function() { if ($scope.currentTrack) { @@ -763,7 +765,7 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula }; function playScopeName() { - let listId = playlistService.getCurrentPlaylistId(); + let listId = playQueueService.getCurrentPlaylistId(); if (listId !== null) { let key = listId.split('-').slice(0, -1).join('-') || listId; return playScopeNames[key]; @@ -807,66 +809,42 @@ function ($scope, $rootScope, playlistService, Audio, gettextCatalog, Restangula }); /** - * Integration to the media control panel available on Chrome starting from version 73 and Edge from - * version 83. In Firefox, the API is enabled by default at least in the version 83, although at least - * partial support has been available already starting from the version 74 via the advanced settings. - * - * The API brings the bindings with the special multimedia keys possibly present on the keyboard, - * as well as any OS multimedia controls available e.g. in status pane and/or lock screen. + * Media session API */ - if ('mediaSession' in navigator) { - let registerMediaControlHandler = function(action, handler) { - try { - navigator.mediaSession.setActionHandler(action, function() { $scope.$apply((_scope) => handler()); }); - } catch (error) { - console.log('The media control "' + action + '"" is not supported by the browser'); - } - }; - - registerMediaControlHandler('play', $scope.play); - registerMediaControlHandler('pause', $scope.pause); - registerMediaControlHandler('stop', $scope.stop); - registerMediaControlHandler('seekbackward', $scope.seekBackward); - registerMediaControlHandler('seekforward', $scope.seekForward); - registerMediaControlHandler('previoustrack', $scope.prev); - registerMediaControlHandler('nexttrack', $scope.next); + browserMediaSession.registerControls({ + play: () => $scope.play(), + pause: () => $scope.pause(), + stop: () => $scope.stop(), + seekBackward: () => $scope.seekBackward(), + seekForward: () => $scope.seekForward(), + previousTrack: () => $scope.prev(), + nextTrack: () => $scope.next() + }); - $scope.$watchGroup(['currentTrack', 'currentTrack.metadata.title'], function(newValues) { - const track = newValues[0]; - if (track) { - if (track.type === 'radio') { - navigator.mediaSession.metadata = new MediaMetadata({ - title: $scope.primaryTitle(), - artist: $scope.secondaryTitle(), - artwork: [{ - sizes: '190x190', - src: radioIconPath, - type: 'image/svg+xml' - }] - }); - } - else { - navigator.mediaSession.metadata = new MediaMetadata({ - title: track.title, - artist: track?.artist?.name, - album: track?.album?.name ?? track?.channel?.title, - artwork: [{ - sizes: '190x190', - src: $scope.coverArt() + (coverArtToken ? ('?coverToken=' + coverArtToken) : ''), - type: '' - }] - }); - } + $scope.$watchGroup(['currentTrack', 'currentTrack.metadata.title'], function(newValues) { + const track = newValues[0]; + if (track) { + if (track.type === 'radio') { + browserMediaSession.showInfo({ + title: $scope.primaryTitle(), + artist: $scope.secondaryTitle(), + cover: radioIconPath, + coverMime: 'image/svg+xml' + }); } else { - navigator.mediaSession.metadata = null; + browserMediaSession.showInfo({ + title: track.title, + artist: track?.artist?.name, + album: track?.album?.name ?? track?.channel?.title, + cover: $scope.coverArt() + (coverArtToken ? ('?coverToken=' + coverArtToken) : ''), + }); } - }); - - $rootScope.$watch('playing', function(isPlaying) { - navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; - }); - } + } + else { + browserMediaSession.clearInfo(); + } + }); /** * Desktop notifications diff --git a/js/app/controllers/sidebar/albumdetailscontroller.js b/js/app/controllers/sidebar/albumdetailscontroller.js index 7c08cb2db..99d1f2493 100644 --- a/js/app/controllers/sidebar/albumdetailscontroller.js +++ b/js/app/controllers/sidebar/albumdetailscontroller.js @@ -10,8 +10,8 @@ angular.module('Music').controller('AlbumDetailsController', [ - '$rootScope', '$scope', '$timeout', 'Restangular', 'gettextCatalog', 'libraryService', 'playlistService', - function ($rootScope, $scope, $timeout, Restangular, gettextCatalog, libraryService, playlistService) { + '$rootScope', '$scope', '$timeout', 'Restangular', 'gettextCatalog', 'libraryService', 'playQueueService', + function ($rootScope, $scope, $timeout, Restangular, gettextCatalog, libraryService, playQueueService) { function resetContents() { $scope.album = null; @@ -112,7 +112,7 @@ angular.module('Music').controller('AlbumDetailsController', [ const currentTrack = $scope.$parent.currentTrack; if (currentTrack?.id === trackId && currentTrack?.type == 'song') { // play/pause if currently playing list item clicked - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } else { // on any other list item, start playing the list from this item playTracks('album-' + $scope.album.id, $scope.album.tracks, index); @@ -142,8 +142,8 @@ angular.module('Music').controller('AlbumDetailsController', [ let playlist = _.map(tracks, (track) => { return { track: track }; }); - playlistService.setPlaylist(listId, playlist, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, playlist, startIndex); + playQueueService.publish('play'); } $scope.$watch('contentId', function(newId) { diff --git a/js/app/controllers/sidebar/artistdetailscontroller.js b/js/app/controllers/sidebar/artistdetailscontroller.js index 8ae2b896d..79cb45dce 100644 --- a/js/app/controllers/sidebar/artistdetailscontroller.js +++ b/js/app/controllers/sidebar/artistdetailscontroller.js @@ -10,8 +10,8 @@ angular.module('Music').controller('ArtistDetailsController', [ - '$rootScope', '$scope', 'Restangular', 'gettextCatalog', 'libraryService', 'playlistService', - function ($rootScope, $scope, Restangular, gettextCatalog, libraryService, playlistService) { + '$rootScope', '$scope', 'Restangular', 'gettextCatalog', 'libraryService', 'playQueueService', + function ($rootScope, $scope, Restangular, gettextCatalog, libraryService, playQueueService) { function resetContents() { $scope.artist = null; @@ -140,7 +140,7 @@ angular.module('Music').controller('ArtistDetailsController', [ const currentTrack = $scope.$parent.currentTrack; if (currentTrack?.id === trackId && currentTrack?.type == 'song') { // play/pause if currently playing list item clicked - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } else { // on any other list item, start playing the list from this item playTracks('artist-tracks-' + $scope.artist.id, $scope.artistTracks, index); @@ -151,8 +151,8 @@ angular.module('Music').controller('ArtistDetailsController', [ let playlist = _.map(tracks, function(track) { return { track: track }; }); - playlistService.setPlaylist(listId, playlist, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, playlist, startIndex); + playQueueService.publish('play'); } $scope.onShowAllSimilar = function() { diff --git a/js/app/controllers/views/advancedsearchviewcontroller.js b/js/app/controllers/views/advancedsearchviewcontroller.js index 10c696030..df9adb174 100644 --- a/js/app/controllers/views/advancedsearchviewcontroller.js +++ b/js/app/controllers/views/advancedsearchviewcontroller.js @@ -9,8 +9,8 @@ */ angular.module('Music').controller('AdvancedSearchViewController', [ - '$rootScope', '$scope', 'libraryService', 'playlistService', '$timeout', 'Restangular', 'gettextCatalog', - function ($rootScope, $scope, libraryService, playlistService, $timeout, Restangular, gettextCatalog) { + '$rootScope', '$scope', 'libraryService', 'playQueueService', '$timeout', 'Restangular', 'gettextCatalog', + function ($rootScope, $scope, libraryService, playQueueService, $timeout, Restangular, gettextCatalog) { $rootScope.currentView = $scope.getViewIdFromUrl(); @@ -428,8 +428,8 @@ angular.module('Music').controller('AdvancedSearchViewController', [ return { track: track }; }); - playlistService.setPlaylist('adv_search_results' + $scope.results.id, playlist, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist('adv_search_results' + $scope.results.id, playlist, startIndex); + playQueueService.publish('play'); } function getTracksFromResult() { @@ -442,7 +442,7 @@ angular.module('Music').controller('AdvancedSearchViewController', [ return [].concat(trackResults, tracksFromAlbums, tracksFromArtists, tracksFromPlaylists, episodeResults, episodesFromChannels); } - // Call playlistService to play all songs in the current playlist from the beginning + // Call playQueueService to play all songs in the current playlist from the beginning $scope.onHeaderClick = function() { play(getTracksFromResult()); }; @@ -462,7 +462,7 @@ angular.module('Music').controller('AdvancedSearchViewController', [ // play/pause if currently playing list item clicked const currentTrack = $scope.$parent.currentTrack; if (currentTrack && currentTrack.id === trackId && currentTrack.type == 'song') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { @@ -535,8 +535,8 @@ angular.module('Music').controller('AdvancedSearchViewController', [ $scope.onPlaylistClick = function(playlistId) { // TODO: play/pause if currently playing playlist clicked? - playlistService.setPlaylist('playlist-' + playlistId, libraryService.getPlaylist(playlistId).tracks); - playlistService.publish('play', '#/playlist/' + playlistId); + playQueueService.setPlaylist('playlist-' + playlistId, libraryService.getPlaylist(playlistId).tracks); + playQueueService.publish('play', '#/playlist/' + playlistId); }; $scope.getPlaylistData = function(listItem, index, _scope) { @@ -557,7 +557,7 @@ angular.module('Music').controller('AdvancedSearchViewController', [ $scope.onPodcastEpisodeClick = function(episodeId) { const currentTrack = $scope.$parent.currentTrack; if (currentTrack && currentTrack.id === episodeId && currentTrack.type == 'podcast') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { diff --git a/js/app/controllers/views/albumsviewcontroller.js b/js/app/controllers/views/albumsviewcontroller.js index e3b6a06e2..ba3fd9453 100644 --- a/js/app/controllers/views/albumsviewcontroller.js +++ b/js/app/controllers/views/albumsviewcontroller.js @@ -11,9 +11,9 @@ */ angular.module('Music').controller('AlbumsViewController', [ - '$scope', '$rootScope', 'playlistService', 'libraryService', + '$scope', '$rootScope', 'playQueueService', 'libraryService', 'Restangular', '$document', '$route', '$location', '$timeout', 'gettextCatalog', - function ($scope, $rootScope, playlistService, libraryService, + function ($scope, $rootScope, playQueueService, libraryService, Restangular, $document, $route, $location, $timeout, gettextCatalog) { $rootScope.currentView = '#'; @@ -34,8 +34,9 @@ angular.module('Music').controller('AlbumsViewController', [ unsubFuncs.push( $rootScope.$on(event, handler) ); } - $scope.$on('$destroy', function () { + $scope.$on('$destroy', () => { _.each(unsubFuncs, function(func) { func(); }); + playQueueService.unsubscribeAll(this); }); // Prevent controller reload when the URL is updated with window.location.hash, @@ -53,8 +54,8 @@ angular.module('Music').controller('AlbumsViewController', [ let playlist = _.map(tracks, function(track) { return { track: track }; }); - playlistService.setPlaylist(listId, playlist, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, playlist, startIndex); + playQueueService.publish('play'); } function playPlaylistFromTrack(listId, playlist, track) { @@ -62,10 +63,10 @@ angular.module('Music').controller('AlbumsViewController', [ window.location.hash = '#/track/' + track.id; let index = _.findIndex(playlist, function(i) {return i.track.id == track.id;}); - playlistService.setPlaylist(listId, playlist, index); + playQueueService.setPlaylist(listId, playlist, index); let startOffset = $location.search().offset || null; - playlistService.publish('play', null, startOffset); + playQueueService.publish('play', null, startOffset); $location.search('offset', null); // the offset parameter has been used up } @@ -75,15 +76,15 @@ angular.module('Music').controller('AlbumsViewController', [ // play/pause if currently playing track clicked if (currentTrack && track.id === currentTrack.id && currentTrack.type == 'song') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } else { - let currentListId = playlistService.getCurrentPlaylistId(); + let currentListId = playQueueService.getCurrentPlaylistId(); // start playing the album/artist from this track if the clicked track belongs // to album/artist which is the current play scope if (currentListId === 'album-' + track.album.id || currentListId === 'artist-' + track.album.artist.id) { - playPlaylistFromTrack(currentListId, playlistService.getCurrentPlaylist(), track); + playPlaylistFromTrack(currentListId, playQueueService.getCurrentPlaylist(), track); } // on any other track, start playing the collection from this track else { @@ -173,25 +174,24 @@ angular.module('Music').controller('AlbumsViewController', [ return att; } - // emitted on end of playlist by playerController - subscribe('playlistEnded', function() { + playQueueService.subscribe('playlistEnded', function() { window.location.hash = '#/'; updateHighlight(null); - }); + }, this); - subscribe('playlistChanged', function(e, playlistId) { + playQueueService.subscribe('playlistChanged', function(playlistId) { updateHighlight(playlistId); - }); + }, this); - subscribe('scrollToTrack', function(event, trackId, animationTime /* optional */) { + subscribe('scrollToTrack', function(_event, trackId, animationTime /* optional */) { scrollToAlbumOfTrack(trackId, animationTime); }); - subscribe('scrollToAlbum', function(event, albumId, animationTime /* optional */) { + subscribe('scrollToAlbum', function(_event, albumId, animationTime /* optional */) { $scope.$parent.scrollToItem('album-' + albumId, animationTime); }); - subscribe('scrollToArtist', function(event, artistId, animationTime /* optional */) { + subscribe('scrollToArtist', function(_event, artistId, animationTime /* optional */) { const elemId = 'artist-' + artistId; if ($('#' + elemId).length) { $scope.$parent.scrollToItem(elemId, animationTime); @@ -277,7 +277,7 @@ angular.module('Music').controller('AlbumsViewController', [ } } - updateHighlight(playlistService.getCurrentPlaylistId()); + updateHighlight(playQueueService.getCurrentPlaylistId()); } /** @@ -300,7 +300,7 @@ angular.module('Music').controller('AlbumsViewController', [ if (!isPlaying()) { $timeout(initializePlayerStateFromURL); } else { - updateHighlight(playlistService.getCurrentPlaylistId()); + updateHighlight(playQueueService.getCurrentPlaylistId()); } $timeout(() => $rootScope.$emit('viewActivated')); diff --git a/js/app/controllers/views/alltracksviewcontroller.js b/js/app/controllers/views/alltracksviewcontroller.js index 37497b831..b9ff1e659 100644 --- a/js/app/controllers/views/alltracksviewcontroller.js +++ b/js/app/controllers/views/alltracksviewcontroller.js @@ -10,8 +10,8 @@ angular.module('Music').controller('AllTracksViewController', [ - '$rootScope', '$scope', 'playlistService', 'libraryService', 'alphabetIndexingService', '$timeout', - function ($rootScope, $scope, playlistService, libraryService, alphabetIndexingService, $timeout) { + '$rootScope', '$scope', 'playQueueService', 'libraryService', 'alphabetIndexingService', '$timeout', + function ($rootScope, $scope, playQueueService, libraryService, alphabetIndexingService, $timeout) { $rootScope.currentView = $scope.getViewIdFromUrl(); @@ -24,7 +24,7 @@ angular.module('Music').controller('AllTracksViewController', [ const BUCKET_MAX_SIZE = 100; $scope.trackBuckets = null; - // $rootScope listeneres must be unsubscribed manually when the control is destroyed + // $rootScope listeners must be unsubscribed manually when the control is destroyed let _unsubFuncs = []; function subscribe(event, handler) { @@ -36,11 +36,11 @@ angular.module('Music').controller('AllTracksViewController', [ }); function play(startIndex = null) { - playlistService.setPlaylist('alltracks', _tracks, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist('alltracks', _tracks, startIndex); + playQueueService.publish('play'); } - // Call playlistService to play all songs in the current playlist from the beginning + // Call playQueueService to play all songs in the current playlist from the beginning $scope.onHeaderClick = function() { play(); }; @@ -50,7 +50,7 @@ angular.module('Music').controller('AllTracksViewController', [ // play/pause if currently playing list item clicked const currentTrack = $scope.$parent.currentTrack; if (currentTrack && currentTrack.id === trackId && currentTrack.type == 'song') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { diff --git a/js/app/controllers/views/foldersviewcontroller.js b/js/app/controllers/views/foldersviewcontroller.js index 56938e31a..cb3f575a9 100644 --- a/js/app/controllers/views/foldersviewcontroller.js +++ b/js/app/controllers/views/foldersviewcontroller.js @@ -10,8 +10,8 @@ angular.module('Music').controller('FoldersViewController', [ - '$rootScope', '$scope', '$timeout', '$document', 'playlistService', 'libraryService', - function ($rootScope, $scope, $timeout, $document, playlistService, libraryService) { + '$rootScope', '$scope', '$timeout', '$document', 'playQueueService', 'libraryService', + function ($rootScope, $scope, $timeout, $document, playQueueService, libraryService) { $scope.folders = null; $scope.rootFolder = null; @@ -35,8 +35,8 @@ angular.module('Music').controller('FoldersViewController', [ if (startFromTrackId !== undefined) { startIndex = _.findIndex(tracks, (i) => i.track.id == startFromTrackId); } - playlistService.setPlaylist(listId, tracks, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, tracks, startIndex); + playQueueService.publish('play'); } $scope.onFolderTitleClick = function(folder) { @@ -47,15 +47,15 @@ angular.module('Music').controller('FoldersViewController', [ // play/pause if currently playing folder item clicked const currentTrack = $scope.$parent.currentTrack; if (currentTrack && currentTrack.id === trackId && currentTrack.type == 'song') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing from this item else { // change the track within the playscope if the clicked track belongs to the current folder scope if (trackBelongsToPlayingFolder(trackId)) { playPlaylist( - playlistService.getCurrentPlaylistId(), - playlistService.getCurrentPlaylist(), + playQueueService.getCurrentPlaylistId(), + playQueueService.getCurrentPlaylist(), trackId); } // on any other case, start playing the collection from this track @@ -66,9 +66,9 @@ angular.module('Music').controller('FoldersViewController', [ }; function trackBelongsToPlayingFolder(trackId) { - let currentListId = playlistService.getCurrentPlaylistId(); + let currentListId = playQueueService.getCurrentPlaylistId(); if (currentListId?.startsWith('folder-')) { - let currentList = playlistService.getCurrentPlaylist(); + let currentList = playQueueService.getCurrentPlaylist(); return (0 <= _.findIndex(currentList, ['track.id', trackId])); } else { return false; @@ -125,15 +125,15 @@ angular.module('Music').controller('FoldersViewController', [ return 'folder-' + $scope.folders[index].id; }; - subscribe('playlistEnded', function() { + playQueueService.subscribe('playlistEnded', function() { updateHighlight(null); - }); + }, this); - subscribe('playlistChanged', function(e, playlistId) { + playQueueService.subscribe('playlistChanged', function(playlistId) { updateHighlight(playlistId); - }); + }, this); - subscribe('scrollToTrack', function(event, trackId) { + subscribe('scrollToTrack', function(_event, trackId) { if ($scope.$parent) { let elementId = 'track-' + trackId; // If the track element is hidden (collapsed), scroll to the folder @@ -147,8 +147,9 @@ angular.module('Music').controller('FoldersViewController', [ } }); - $scope.$on('$destroy', function () { + $scope.$on('$destroy', () => { _.each(unsubFuncs, function(func) { func(); }); + playQueueService.unsubscribeAll(this); }); // Init happens either immediately (after making the loading animation visible) @@ -188,7 +189,7 @@ angular.module('Music').controller('FoldersViewController', [ } /** - * Increase number of shown folders aynchronously step-by-step until + * Increase number of shown folders asynchronously step-by-step until * they are all visible. This is to avoid script hanging up for too * long on huge collections. */ @@ -206,7 +207,7 @@ angular.module('Music').controller('FoldersViewController', [ function onViewReady() { $rootScope.loading = false; - updateHighlight(playlistService.getCurrentPlaylistId()); + updateHighlight(playQueueService.getCurrentPlaylistId()); $rootScope.$emit('viewActivated'); } diff --git a/js/app/controllers/views/genresviewcontroller.js b/js/app/controllers/views/genresviewcontroller.js index 189deaabc..6db1af35a 100644 --- a/js/app/controllers/views/genresviewcontroller.js +++ b/js/app/controllers/views/genresviewcontroller.js @@ -10,8 +10,8 @@ angular.module('Music').controller('GenresViewController', [ - '$rootScope', '$scope', 'playlistService', 'libraryService', '$timeout', - function ($rootScope, $scope, playlistService, libraryService, $timeout) { + '$rootScope', '$scope', 'playQueueService', 'libraryService', '$timeout', + function ($rootScope, $scope, playQueueService, libraryService, $timeout) { $scope.genres = null; $rootScope.currentView = $scope.getViewIdFromUrl(); @@ -39,8 +39,8 @@ angular.module('Music').controller('GenresViewController', [ if (startFromTrackId !== undefined) { startIndex = _.findIndex(tracks, (i) => i.track.id == startFromTrackId); } - playlistService.setPlaylist(listId, tracks, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, tracks, startIndex); + playQueueService.publish('play'); } $scope.onGenreTitleClick = function(genre) { @@ -51,11 +51,11 @@ angular.module('Music').controller('GenresViewController', [ // play/pause if currently playing item clicked const currentTrack = $scope.$parent.currentTrack; if (currentTrack && currentTrack.id === trackId && currentTrack.type == 'song') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the genre or whole library from this item else { - let currentListId = playlistService.getCurrentPlaylistId(); + let currentListId = playQueueService.getCurrentPlaylistId(); let genre = libraryService.getTrack(trackId).genre; // start playing the genre from this track if the clicked track belongs @@ -123,15 +123,15 @@ angular.module('Music').controller('GenresViewController', [ return 'genre-' + $scope.genres[index].id; }; - subscribe('playlistEnded', function() { + playQueueService.subscribe('playlistEnded', function() { updateHighlight(null); - }); + }, this); - subscribe('playlistChanged', function(e, playlistId) { + playQueueService.subscribe('playlistChanged', function(playlistId) { updateHighlight(playlistId); - }); + }, this); - subscribe('scrollToTrack', function(event, trackId) { + subscribe('scrollToTrack', function(_event, trackId) { if ($scope.$parent) { let elementId = 'track-' + trackId; // If the track element is hidden (collapsed), scroll to the genre @@ -145,8 +145,9 @@ angular.module('Music').controller('GenresViewController', [ } }); - $scope.$on('$destroy', function () { + $scope.$on('$destroy', () => { _.each(unsubFuncs, function(func) { func(); }); + playQueueService.unsubscribeAll(this); }); // Init happens either immediately (after making the loading animation visible) @@ -174,7 +175,7 @@ angular.module('Music').controller('GenresViewController', [ } /** - * Increase number of shown genres aynchronously step-by-step until + * Increase number of shown genres asynchronously step-by-step until * they are all visible. This is to avoid script hanging up for too * long on huge collections. */ @@ -186,7 +187,7 @@ angular.module('Music').controller('GenresViewController', [ $timeout(showMore); } else { $rootScope.loading = false; - updateHighlight(playlistService.getCurrentPlaylistId()); + updateHighlight(playQueueService.getCurrentPlaylistId()); $rootScope.$emit('viewActivated'); } } diff --git a/js/app/controllers/views/playlistviewcontroller.js b/js/app/controllers/views/playlistviewcontroller.js index a354cc660..ea038ce6b 100644 --- a/js/app/controllers/views/playlistviewcontroller.js +++ b/js/app/controllers/views/playlistviewcontroller.js @@ -7,14 +7,14 @@ * @author Morris Jobke * @author Pauli Järvinen * @copyright Morris Jobke 2013 - * @copyright Pauli Järvinen 2017 - 2023 + * @copyright Pauli Järvinen 2017 - 2024 */ angular.module('Music').controller('PlaylistViewController', [ - '$rootScope', '$scope', '$routeParams', 'playlistService', 'libraryService', + '$rootScope', '$scope', '$routeParams', 'playQueueService', 'libraryService', 'gettextCatalog', 'Restangular', '$timeout', - function ($rootScope, $scope, $routeParams, playlistService, libraryService, + function ($rootScope, $scope, $routeParams, playQueueService, libraryService, gettextCatalog, Restangular, $timeout) { const INCREMENTAL_LOAD_STEP = 1000; @@ -22,7 +22,7 @@ angular.module('Music').controller('PlaylistViewController', [ $scope.tracks = null; $rootScope.currentView = $scope.getViewIdFromUrl(); - // $rootScope listeneres must be unsubscribed manually when the control is destroyed + // $rootScope listeners must be unsubscribed manually when the control is destroyed let unsubFuncs = []; function subscribe(event, handler) { @@ -50,7 +50,7 @@ angular.module('Music').controller('PlaylistViewController', [ if (trackIndex <= playingIndex) { --playingIndex; } - playlistService.onPlaylistModified($scope.tracks, playingIndex); + playQueueService.onPlaylistModified($scope.tracks, playingIndex); } Restangular.one('playlists', listId).all('remove').post({indices: trackIndex}).then(function (result) { @@ -60,11 +60,11 @@ angular.module('Music').controller('PlaylistViewController', [ function play(startIndex = null) { let id = 'playlist-' + $scope.playlist.id; - playlistService.setPlaylist(id, $scope.tracks, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist(id, $scope.tracks, startIndex); + playQueueService.publish('play'); } - // Call playlistService to play all songs in the current playlist from the beginning + // Call playQueueService to play all songs in the current playlist from the beginning $scope.onHeaderClick = function() { play(); }; @@ -73,7 +73,7 @@ angular.module('Music').controller('PlaylistViewController', [ $scope.onTrackClick = function(trackIndex) { // play/pause if currently playing list item clicked if ($scope.getCurrentTrackIndex() === trackIndex) { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { @@ -109,7 +109,7 @@ angular.module('Music').controller('PlaylistViewController', [ ++playingIndex; } } - playlistService.onPlaylistModified($scope.tracks, playingIndex); + playQueueService.onPlaylistModified($scope.tracks, playingIndex); } Restangular.one('playlists', listId).all('reorder').post({fromIndex: srcIndex, toIndex: dstIndex}).then(function (result) { diff --git a/js/app/controllers/views/podcastsviewcontroller.js b/js/app/controllers/views/podcastsviewcontroller.js index 1d330f76a..8a4c5a82e 100644 --- a/js/app/controllers/views/podcastsviewcontroller.js +++ b/js/app/controllers/views/podcastsviewcontroller.js @@ -5,37 +5,38 @@ * later. See the COPYING file. * * @author Pauli Järvinen - * @copyright Pauli Järvinen 2021 - 2023 + * @copyright Pauli Järvinen 2021 - 2024 */ angular.module('Music').controller('PodcastsViewController', [ - '$scope', '$rootScope', 'playlistService', 'podcastService', 'libraryService', '$timeout', 'gettextCatalog', - function ($scope, $rootScope, playlistService, podcastService, libraryService, $timeout, gettextCatalog) { + '$scope', '$rootScope', 'playQueueService', 'podcastService', 'libraryService', '$timeout', 'gettextCatalog', + function ($scope, $rootScope, playQueueService, podcastService, libraryService, $timeout, gettextCatalog) { $rootScope.currentView = $scope.getViewIdFromUrl(); - // $rootScope listeneres must be unsubscribed manually when the control is destroyed + // $rootScope listeners must be unsubscribed manually when the control is destroyed let unsubFuncs = []; function subscribe(event, handler) { unsubFuncs.push( $rootScope.$on(event, handler) ); } - $scope.$on('$destroy', function () { + $scope.$on('$destroy', () => { _.each(unsubFuncs, function(func) { func(); }); + playQueueService.unsubscribeAll(this); }); // Wrap the supplied tracks as a playlist and pass it to the service for playing function playEpisodes(listId, episodes) { let playlist = _.map(episodes, (episode) => ({track: episode})); - playlistService.setPlaylist(listId, playlist); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, playlist); + playQueueService.publish('play'); } function playPlaylistFromEpisode(listId, playlist, episode) { let index = _.findIndex(playlist, function(i) {return i.track.id == episode.id;}); - playlistService.setPlaylist(listId, playlist, index); - playlistService.publish('play'); + playQueueService.setPlaylist(listId, playlist, index); + playQueueService.publish('play'); } $scope.playEpisode = function(episodeId) { @@ -44,15 +45,15 @@ angular.module('Music').controller('PodcastsViewController', [ // play/pause if currently playing track clicked if (currentTrack && episode.id === currentTrack.id && currentTrack.type === 'podcast') { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } else { - let currentListId = playlistService.getCurrentPlaylistId(); + let currentListId = playQueueService.getCurrentPlaylistId(); // start playing the channel from this episode if the clicked track belongs // to a channel which is the current play scope if (currentListId === 'podcast-channel-' + episode.channel.id) { - playPlaylistFromEpisode(currentListId, playlistService.getCurrentPlaylist(), episode); + playPlaylistFromEpisode(currentListId, playQueueService.getCurrentPlaylist(), episode); } // on any other episode, start playing just the episode else { @@ -113,14 +114,13 @@ angular.module('Music').controller('PodcastsViewController', [ return gettextCatalog.getString('Show all {{ count }} episodes …', { count: count }); }; - // emited on end of playlist by playerController - subscribe('playlistEnded', function() { + playQueueService.subscribe('playlistEnded', function() { updateHighlight(null); - }); + }, this); - subscribe('playlistChanged', function(_event, playlistId) { + playQueueService.subscribe('playlistChanged', function(playlistId) { updateHighlight(playlistId); - }); + }, this); subscribe('scrollToPodcastEpisode', function(_event, episodeId, animationTime = 500) { let episode = libraryService.getPodcastEpisode(episodeId); @@ -168,7 +168,7 @@ angular.module('Music').controller('PodcastsViewController', [ } } - // Make the content visible immediatedly if the podcasts are already loaded. + // Make the content visible immediately if the podcasts are already loaded. // Otherwise it happens on the 'podcastsLoaded' event handler. if (libraryService.podcastsLoaded()) { onContentReady(); diff --git a/js/app/controllers/views/radioviewcontroller.js b/js/app/controllers/views/radioviewcontroller.js index 4e422eacf..4f1aed26a 100644 --- a/js/app/controllers/views/radioviewcontroller.js +++ b/js/app/controllers/views/radioviewcontroller.js @@ -5,20 +5,20 @@ * later. See the COPYING file. * * @author Pauli Järvinen - * @copyright Pauli Järvinen 2020 - 2023 + * @copyright Pauli Järvinen 2020 - 2024 */ angular.module('Music').controller('RadioViewController', [ - '$rootScope', '$scope', 'playlistService', 'libraryService', 'gettextCatalog', 'Restangular', '$timeout', - function ($rootScope, $scope, playlistService, libraryService, gettextCatalog, Restangular, $timeout) { + '$rootScope', '$scope', 'playQueueService', 'libraryService', 'gettextCatalog', 'Restangular', '$timeout', + function ($rootScope, $scope, playQueueService, libraryService, gettextCatalog, Restangular, $timeout) { const INCREMENTAL_LOAD_STEP = 1000; $scope.incrementalLoadLimit = INCREMENTAL_LOAD_STEP; $scope.stations = null; $rootScope.currentView = $scope.getViewIdFromUrl(); - // $rootScope listeneres must be unsubscribed manually when the control is destroyed + // $rootScope listeners must be unsubscribed manually when the control is destroyed let unsubFuncs = []; function subscribe(event, handler) { @@ -59,7 +59,7 @@ angular.module('Music').controller('RadioViewController', [ if (removedIndex <= playingIndex) { --playingIndex; } - playlistService.onPlaylistModified($scope.stations, playingIndex); + playQueueService.onPlaylistModified($scope.stations, playingIndex); } // Fire an event to tell the alphabet navigation about the change. This must happen asynchronously // to ensure that the alphabet navigation has up-to-date item count available when it handles the event. @@ -76,11 +76,11 @@ angular.module('Music').controller('RadioViewController', [ } function play(startIndex = null) { - playlistService.setPlaylist('radio', $scope.stations, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist('radio', $scope.stations, startIndex); + playQueueService.publish('play'); } - // Call playlistService to play all songs in the current playlist from the beginning + // Call playQueueService to play all songs in the current playlist from the beginning $scope.onHeaderClick = function() { play(); }; @@ -89,7 +89,7 @@ angular.module('Music').controller('RadioViewController', [ $scope.onStationClick = function(stationIndex) { // play/pause if currently playing list item clicked if ($scope.getCurrentStationIndex() === stationIndex) { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { @@ -112,7 +112,7 @@ angular.module('Music').controller('RadioViewController', [ if (listIsPlaying()) { let playingIndex = _.findIndex($scope.stations, { track: $scope.$parent.currentTrack }); - playlistService.onPlaylistModified($scope.stations, playingIndex); + playQueueService.onPlaylistModified($scope.stations, playingIndex); } // Fire an event to tell the alphabet navigation about the change. This must happen asynchronously @@ -155,7 +155,7 @@ angular.module('Music').controller('RadioViewController', [ } /** - * Increase number of shown stations aynchronously step-by-step until + * Increase number of shown stations asynchronously step-by-step until * they are all visible. This is to avoid script hanging up for too * long on huge collections. */ diff --git a/js/app/controllers/views/smartlistviewcontroller.js b/js/app/controllers/views/smartlistviewcontroller.js index b4431c420..8763c43d1 100644 --- a/js/app/controllers/views/smartlistviewcontroller.js +++ b/js/app/controllers/views/smartlistviewcontroller.js @@ -5,19 +5,19 @@ * later. See the COPYING file. * * @author Pauli Järvinen - * @copyright Pauli Järvinen 2023 + * @copyright Pauli Järvinen 2023, 2024 */ angular.module('Music').controller('SmartListViewController', [ - '$rootScope', '$scope', 'playlistService', 'libraryService', '$timeout', - function ($rootScope, $scope, playlistService, libraryService, $timeout) { + '$rootScope', '$scope', 'playQueueService', 'libraryService', '$timeout', + function ($rootScope, $scope, playQueueService, libraryService, $timeout) { $rootScope.currentView = $scope.getViewIdFromUrl(); $scope.tracks = null; - // $rootScope listeneres must be unsubscribed manually when the control is destroyed + // $rootScope listeners must be unsubscribed manually when the control is destroyed let _unsubFuncs = []; function subscribe(event, handler) { @@ -29,18 +29,18 @@ angular.module('Music').controller('SmartListViewController', [ }); function play(startIndex = null) { - playlistService.setPlaylist('smartlist', $scope.tracks, startIndex); - playlistService.publish('play'); + playQueueService.setPlaylist('smartlist', $scope.tracks, startIndex); + playQueueService.publish('play'); } - // Call playlistService to play all songs in the current playlist from the beginning + // Call playQueueService to play all songs in the current playlist from the beginning $scope.onHeaderClick = play; // Play the list, starting from a specific track $scope.onTrackClick = function(trackId) { // play/pause if currently playing list item clicked if ($scope.$parent.currentTrack && $scope.$parent.currentTrack.id === trackId) { - playlistService.publish('togglePlayback'); + playQueueService.publish('togglePlayback'); } // on any other list item, start playing the list from this item else { diff --git a/js/app/services/playlistservice.ts b/js/app/services/playlistservice.ts deleted file mode 100644 index 53de27bf3..000000000 --- a/js/app/services/playlistservice.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * ownCloud - Music app - * - * This file is licensed under the Affero General Public License version 3 or - * later. See the COPYING file. - * - * @author Morris Jobke - * @author Pauli Järvinen - * @copyright Morris Jobke 2013 - * @copyright Pauli Järvinen 2017 - 2023 - */ - -import * as ng from "angular"; -import * as _ from "lodash"; -import { MusicRootScope } from "app/config/musicrootscope"; - -interface PlaylistEntry { -}; - -ng.module('Music').service('playlistService', ['$rootScope', function($rootScope : MusicRootScope) { - let playlist : PlaylistEntry[]|null = null; - let playlistId : string|null = null; - let playOrder : number[] = []; - let playOrderIter = -1; - let startFromIndex : number|null = null; - let shuffle = false; - let repeat = false; - let prevShuffleState = false; - - function shuffledIndices() : number[] { - let indices = _.range(playlist.length); - return _.shuffle(indices); - } - - function shuffledIndicesExcluding(toExclude : number) : number[] { - let indices = _.range(playlist.length); - indices.splice(toExclude, 1); - return _.shuffle(indices); - } - - function wrapIndexToStart(list : Type[], index : number) : Type[] { - if (index > 0) { - // slice array in two parts and interchange them - let begin = list.slice(0, index); - let end = list.slice(index); - list = end.concat(begin); - } - return list; - } - - function enqueueIndices() : void { - let nextIndices : number[] = null; - - if (shuffle) { - if (startFromIndex !== null) { - nextIndices = [startFromIndex].concat(shuffledIndicesExcluding(startFromIndex)); - } else { - nextIndices = shuffledIndices(); - } - // if the next index ended up to be tha same as the pervious one, flip - // it to the end of the order - if (playlist.length > 1 && _.last(playOrder) == _.first(nextIndices)) { - nextIndices = wrapIndexToStart(nextIndices, 1); - } - } - else { - nextIndices = _.range(playlist.length); - if (startFromIndex !== null) { - nextIndices = wrapIndexToStart(nextIndices, startFromIndex); - } - } - - playOrder = playOrder.concat(nextIndices); - prevShuffleState = shuffle; - } - - // drop the planned play order but preserve the history - function dropFuturePlayOrder() : void { - playOrder = _.take(playOrder, playOrderIter + 1); - } - - function insertMany(hostArray : number[], targetIndex : number, insertedItems : number[]) : void { - hostArray.splice.apply(hostArray, [targetIndex, 0].concat(insertedItems)); - } - - return { - setShuffle(state : boolean) : void { - shuffle = state; - }, - setRepeat(state : boolean) : void { - repeat = state; - }, - getCurrentIndex() : number|null { - return (playOrderIter >= 0) ? playOrder[playOrderIter] : null; - }, - getCurrentPlaylistId() : string|null { - return playlistId; - }, - getCurrentPlaylist() : PlaylistEntry[]|null { - return playlist; - }, - jumpToPrevTrack() : PlaylistEntry|null { - if (playlist && playOrderIter > 0) { - --playOrderIter; - let track = playlist[this.getCurrentIndex()]; - this.publish('trackChanged', track); - return track; - } - return null; - }, - jumpToNextTrack() : PlaylistEntry|null { - if (playlist === null || playOrder === null) { - return null; - } - - // check if shuffle state has changed after the play order was last updated - if (shuffle != prevShuffleState) { - dropFuturePlayOrder(); - startFromIndex = playOrder[playOrderIter]; - playOrder = _.initial(playOrder); // drop also current index as it will be readded on next step - enqueueIndices(); - } - - ++playOrderIter; - - // check if we have run to the end of the enqueued tracks - if (playOrderIter >= playOrder.length) { - if (repeat) { // start another round - enqueueIndices(); - } else { // we are done - this.clearPlaylist(); - return null; - } - } - - let track = playlist[this.getCurrentIndex()]; - this.publish('trackChanged', track); - return track; - }, - peekNextTrack() : PlaylistEntry|null { - // The next track may be peeked only when there are forthcoming tracks already enqueued, not when jumping - // to the next track would start a new round in the Repeat mode - if (playlist === null || playOrder === null || playOrderIter < 0 || playOrderIter >= playOrder.length - 1) { - return null; - } else { - return playlist[playOrder[playOrderIter + 1]]; - } - }, - setPlaylist(listId : string, pl : PlaylistEntry[], startIndex : number|null = null) : void { - playlist = pl.slice(); // copy - startFromIndex = startIndex; - if (listId === playlistId) { - // preserve the history if list wasn't actually changed - dropFuturePlayOrder(); - } else { - // drop the history if list changed - playOrder = []; - playOrderIter = -1; // jumpToNextTrack will move this to first valid index - playlistId = listId; - this.publish('playlistChanged', playlistId); - } - enqueueIndices(); - }, - clearPlaylist() : void { - playOrderIter = -1; - playlist = null; - playlistId = null; - this.publish('playlistEnded'); - }, - onPlaylistModified(pl : PlaylistEntry[], currentIndex : number) : void { - let currentTrack = playlist[this.getCurrentIndex()]; - // check if the track being played is still available in the list - if (pl[currentIndex] === currentTrack) { - // re-init the play-order, erasing any history data - playlist = pl.slice(); // copy - startFromIndex = currentIndex; - playOrder = []; - enqueueIndices(); - playOrderIter = 0; - } - // if not, then we no longer have a valid list position - else { - playlist = null; - playlistId = null; - playOrder = null; - playOrderIter = -1; - } - this.publish('trackChanged', currentTrack); - }, - onTracksAdded(newTracks : PlaylistEntry[]) : void { - let prevListSize = playlist.length; - playlist = playlist.concat(newTracks); - let newIndices = _.range(prevListSize, playlist.length); - if (prevShuffleState) { - // Shuffle the new tracks with the remaining tracks on the list - let remaining = _.drop(playOrder, playOrderIter+1); - remaining = _.shuffle(remaining.concat(newIndices)); - playOrder = _.take(playOrder, playOrderIter+1).concat(remaining); - } - else { - // Try to find the next position of the previously last track of the list, - // and insert the new tracks in play order after that. If the index is not - // found, then we have already wrapped over the last track and the new tracks - // do not need to be added. - let insertPos = _.indexOf(playOrder, prevListSize-1, playOrderIter); - if (insertPos >= 0) { - ++insertPos; - insertMany(playOrder, insertPos, newIndices); - } - } - }, - publish(name : string, ...args : any[]) : void { - $rootScope.$emit(name, ...args); - }, - subscribe(name : string, listener : (event: ng.IAngularEvent, ...args: any[]) => any) : () => void { - return $rootScope.$on(name, listener); - } - }; -}]); diff --git a/js/app/services/playqueueservice.ts b/js/app/services/playqueueservice.ts new file mode 100644 index 000000000..a726b09f0 --- /dev/null +++ b/js/app/services/playqueueservice.ts @@ -0,0 +1,16 @@ +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Morris Jobke + * @author Pauli Järvinen + * @copyright Morris Jobke 2013 + * @copyright Pauli Järvinen 2017 - 2024 + */ + +import * as ng from "angular"; +import { PlayQueue } from "shared/playqueue"; + +ng.module('Music').service('playQueueService', [PlayQueue]); diff --git a/js/dashboard/main.ts b/js/dashboard/main.ts new file mode 100644 index 000000000..14c1f5ef5 --- /dev/null +++ b/js/dashboard/main.ts @@ -0,0 +1,23 @@ +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Pauli Järvinen + * @copyright Pauli Järvinen 2024 + */ + +import { MusicWidget } from './musicwidget'; +import { PlayerWrapper } from 'shared/playerwrapper'; +import { PlayQueue } from 'shared/playqueue'; + +document.addEventListener('DOMContentLoaded', () => { + OCA.Dashboard.register('music', (el : HTMLElement) => { + const $container = $(el); + $container.addClass('music-widget'); + const player = new PlayerWrapper(); + const queue = new PlayQueue(); + const widget = new MusicWidget($container, player, queue); + }); +}); diff --git a/js/dashboard/musicwidget.ts b/js/dashboard/musicwidget.ts new file mode 100644 index 000000000..baf105f9c --- /dev/null +++ b/js/dashboard/musicwidget.ts @@ -0,0 +1,497 @@ +/** + * ownCloud - Music app + * + * This file is licensed under the Affero General Public License version 3 or + * later. See the COPYING file. + * + * @author Pauli Järvinen + * @copyright Pauli Järvinen 2024 + */ + +import { BrowserMediaSession } from "shared/browsermediasession"; +import { PlayerWrapper } from "shared/playerwrapper"; +import { PlayQueue } from "shared/playqueue"; +import { ProgressInfo } from "shared/progressinfo"; +import { VolumeControl } from "shared/volumecontrol"; +import * as _ from 'lodash'; + +declare function t(module : string, text : string) : string; + +const AMPACHE_API_URL = 'apps/music/ampache/server/json.server.php'; + +export class MusicWidget { + + #player: PlayerWrapper; + #queue: PlayQueue; + #volumeControl: VolumeControl; + #progressInfo: ProgressInfo; + #browserMediaSession: BrowserMediaSession; + #selectContainer: JQuery; + #modeSelect: JQuery; + #filterSelects: JQuery[]; + #trackListContainer: JQuery; + #trackList: JQuery; + #progressAndOrder: JQuery; + #currentSongLabel: JQuery; + #controls: JQuery; + #events: typeof OC.Backbone.Events; + #debouncedPlayCurrent: () => void; + + constructor($container: JQuery, player: PlayerWrapper, queue: PlayQueue) { + this.#player = player; + this.#queue = queue; + this.#volumeControl = new VolumeControl(player); + this.#progressInfo = new ProgressInfo(player); + this.#browserMediaSession = new BrowserMediaSession(player); + this.#selectContainer = $('
').appendTo($container); + this.#filterSelects = []; + this.#trackListContainer = $('
').appendTo($container); + this.#events = _.clone(OC.Backbone.Events); + + const modes = [ + { id: 'album_artists', name: t('music', 'Album artists'), onSelect: () => this.#showAlbumArtists() }, + { id: 'track_artists', name: t('music', 'Track artists'), onSelect: () => this.#showTrackArtists() }, + { id: 'albums', name: t('music', 'Albums'), onSelect: () => this.#showAlbums() }, + { id: 'folders', name: t('music', 'Folders'), onSelect: () => this.#showFolders() }, + { id: 'genres', name: t('music', 'Genres'), onSelect: () => this.#showGenres() }, + { id: 'all_tracks', name: t('music', 'All tracks'), onSelect: () => this.#showAllTracks() }, + { id: 'playlists', name: t('music', 'Playlists'), onSelect: () => this.#showPlaylists() }, + { id: 'radio', name: t('music', 'Internet radio'), onSelect: () => this.#showRadioStations() }, + { id: 'podcasts', name: t('music', 'Podcasts'), onSelect: () => this.#showPodcasts() }, + ]; + this.#modeSelect = createSelect(modes, t('music', 'Select mode'), (mode) => { + // clear the previous selections first + this.#filterSelects.forEach((select) => select.remove()); + this.#filterSelects = []; + this.#trackList?.remove(); + this.#trackList = null; + + mode.onSelect(); + }).appendTo(this.#selectContainer); + + this.#progressAndOrder = createProgressAndOrder( + this.#progressInfo, + () => this.#setShuffle(!this.#queue.getShuffle()), + () => this.#setRepeat(!this.#queue.getRepeat()) + ).hide().appendTo($container); + this.#currentSongLabel = $('
').hide().appendTo($container); + this.#controls = createControls( + () => this.#player.play(), + () => this.#player.pause(), + () => this.#onPrevButton(), + () => this.#onNextButton(), + () => this.#scrollToCurrentTrack(), + this.#volumeControl + ).hide().appendTo($container); + + this.#setShuffle(OCA.Music.Storage.get('shuffle') === 'true'); + this.#setRepeat(OCA.Music.Storage.get('repeat') === 'true'); + + this.#debouncedPlayCurrent = _.debounce(() => { + const track = this.#queue.getCurrentTrack() as any; + if (track !== null) { + const $albumArt = this.#controls.find('.albumart'); + this.#loadBackgroundImage($albumArt, track.art); + if ('artist' in track) { + // local song + this.#player.fromUrl(track.url, track.stream_mime); + this.#player.play(); + } else if ('filesize' in track) { + // podcast + this.#player.fromExtUrl(track.url, false); + this.#player.play(); + } else { + // radio stream needs resolving for HLS and playlist-type URLs + $.get(OC.generateUrl('apps/music/api/radio/{id}/streamurl', {id: track.id}), {}, (resolvedStream) => { + this.#player.fromExtUrl(resolvedStream.url, resolvedStream.hls); + this.#player.play(); + }); + } + } + }, 300); + + this.#queue.subscribe('trackChanged', (track) => { + this.#player.pause(); + this.#debouncedPlayCurrent(); + + this.#trackList?.find('.current').removeClass('current'); + this.#trackList?.find(`[data-index='${this.#queue.getCurrentIndex()}']`).addClass('current'); + + this.#controls.find('.albumart').css('background-image', '').addClass('icon-loading'); + + const title = trackTitle(track); + this.#currentSongLabel.html(title.asHtml).attr('title', title.asPlain).show(); + + this.#progressAndOrder.show(); + this.#controls.show(); + + this.#browserMediaSession.showInfo({ + title: track.name, + album: track.album?.name ?? track.channel?.name, + artist: track.artist?.name, + cover: track.art + }); + }); + + this.#queue.subscribe('playlistEnded', () => { + player.stop(); + }); + + this.#player.on('play', () => { + this.#controls.find('.icon-play').hide(); + this.#controls.find('.icon-pause').show(); + }); + + this.#player.on('pause', () => { + this.#controls.find('.icon-play').show(); + this.#controls.find('.icon-pause').hide(); + }); + + this.#player.on('stop', () => { + this.#progressAndOrder.hide(); + this.#currentSongLabel.hide(); + this.#controls.hide(); + this.#trackList.find('.current').removeClass('current'); + }); + + this.#player.on('end', () => this.#onNextButton()); + + this.#browserMediaSession.registerControls({ + play: () => this.#player.play(), + pause: () => this.#player.pause(), + stop: () => this.#player.stop(), + seekBackward: () => this.#player.seekBackward(), + seekForward: () => this.#player.seekForward(), + previousTrack: () => this.#onPrevButton(), + nextTrack: () => this.#onNextButton() + }); + } + + #loadBackgroundImage($albumArt: JQuery, url: string) { + /* Load the image first using an out-of-DOM element and then use the same image + as the background for the element. This is needed because loading the bacground-image + doesn't fire the onload event, making it impossible to timely remove the loading icon. */ + $('').attr('src', url).on('load', function() { + $(this).remove(); // prevent memory leaks + $albumArt.css('background-image', `url(${url})`).removeClass('icon-loading'); + }); + } + + #showAlbumArtists() : void { + this.#ampacheLoadContent('list', { type: 'album_artist' }, (result: any) => { + this.#addFilterSelect(result.list, t('music', 'Select artist'), (artist) => { + if (this.#filterSelects.length > 1) { + this.#filterSelects.pop().remove(); + } + this.#trackList?.remove(); + + this.#ampacheLoadContent('browse', { type: 'artist', filter: artist.id }, (result: any) => { + // Append the "(All albums)" option after the albums + const albumOptions = result.browse.concat([{id: 'all', name: t('music', '(All albums)')}]); + + this.#addFilterSelect(albumOptions, t('music', 'Select album'), (album) => { + if (album.id === 'all') { + const searchArgs = { type: 'song', rule_1: 'album_artist_id', rule_1_operator: 0, rule_1_input: artist.id }; + this.#ampacheLoadAndShowTracks('search', searchArgs, artist.id); + } else { + this.#ampacheLoadAndShowTracks('album_songs', { filter: album.id }, artist.id); + } + }); + }); + }); + }); + } + + #showTrackArtists() : void { + this.#ampacheLoadContent('get_indexes', { type: 'song_artist' }, (result: any) => { + this.#addFilterSelect( + result.artist, + t('music', 'Select artist'), + (artist) => { + this.#ampacheLoadAndShowTracks('artist_songs', { filter: artist.id }, artist.id); + } + ); + }); + } + + #showAlbums() : void { + this.#ampacheLoadContent('get_indexes', { type: 'album' }, (result: any) => { + this.#addFilterSelect( + result.album, + t('music', 'Select album'), + (album) => { + this.#ampacheLoadAndShowTracks('album_songs', { filter: album.id }, album.artist.id); + }, + (album) => `${album.name} (${album.artist.name})` + ); + }); + } + + #showFolders() : void { + this.#ampacheLoadContent('folders', {}, (result: any) => { + this.#addFilterSelect( + result.folder, + t('music', 'Select folder'), + (folder) => { + this.#ampacheLoadAndShowTracks('folder_songs', { filter: folder.id }, null); + }, + (folder) => folder.name || t('music', '(library root)') + ); + }); + } + + #showGenres() : void { + this.#ampacheLoadContent('list', { type: 'genre' }, (result: any) => { + this.#addFilterSelect( + result.list, + t('music', 'Select genre'), + (genre) => { + this.#ampacheLoadAndShowTracks('genre_songs', { filter: genre.id }, null); + } + ); + }); + } + + #showAllTracks() : void { + this.#ampacheLoadAndShowTracks('songs', {}, null); + } + + #showPlaylists() : void { + this.#ampacheLoadContent('list', { type: 'playlist' }, (result: any) => { + this.#addFilterSelect( + result.list, + t('music', 'Select playlist'), + (playlist) => { + this.#ampacheLoadAndShowTracks('playlist_songs', { filter: playlist.id }, null); + } + ); + }); + } + + #showRadioStations() : void { + this.#ampacheLoadAndShowTracks('live_streams', {}, null); + } + + #showPodcasts() : void { + this.#ampacheLoadContent('list', { type: 'podcast' }, (result: any) => { + this.#addFilterSelect( + result.list, + t('music', 'Select channel'), + (channel) => { + this.#ampacheLoadAndShowTracks('podcast_episodes', { filter: channel.id }, channel.id); + } + ); + }); + } + + #addFilterSelect(options: any[], placeholder: string, onChange: (selectedItem: any) => void, fmtTitle: (item: any) => string = null) { + const filter = createSelect(options, placeholder, onChange, fmtTitle).appendTo(this.#selectContainer); + this.#filterSelects.push(filter); + this.#events.trigger('filterPopulated', filter); + } + + #ampacheLoadAndShowTracks(action: string, args: JQuery.PlainObject, parentId: string|null) { + this.#trackList?.remove(); + + const listId = this.#getSelectedListId(); + this.#ampacheLoadContent(action, args, (result: any) => { + this.#listTracks(listId, result.song ?? result.podcast_episode ?? result.live_stream, parentId); + + // highlight the current song if the currently playing list was re-entered + if (this.#queue.getCurrentPlaylistId() == listId) { + this.#trackList.find(`[data-index='${this.#queue.getCurrentIndex()}']`).addClass('current'); + } + + this.#events.trigger('tracksPopulated'); + }); + } + + #ampacheLoadContent(action: string, args: JQuery.PlainObject, callback: (result: any) => void) : void { + this.#trackListContainer.addClass('icon-loading'); + ampacheApiAction(action, args, (result: any) => { + callback(result); + this.#trackListContainer.removeClass('icon-loading'); + }); + } + + #listTracks(listId: string, tracks: any[], parentId: string|null) : void { + const player = this.#player; + const queue = this.#queue; + + this.#trackList = createTrackList(tracks, parentId).appendTo(this.#trackListContainer); + + this.#trackList.on('click', 'li', function(_event) { + const $el = $(this); + const index = $el.data('index'); + if (listId == queue.getCurrentPlaylistId() && index == queue.getCurrentIndex()) { + player.togglePlay(); + } + else { + queue.setPlaylist(listId, tracks, index); + queue.jumpToNextTrack(); + } + }); + } + + #onNextButton() : void { + this.#queue.jumpToNextTrack(); + } + + #onPrevButton() : void { + // When not playing a radio stream, jump to the beginning of the current track if it has + // already played more than 2 secs. Jump to the beginning also in case there is no + // previous track to jump to. + if (this.#playingRadio()) { + this.#queue.jumpToPrevTrack() + } else if (this.#player.playPosition() > 2000 || !this.#queue.jumpToPrevTrack()) { + this.#player.seek(0); + } + } + + #setShuffle(active : boolean) : void { + if (active) { + this.#progressAndOrder.find('.icon-shuffle').addClass('active'); + } else { + this.#progressAndOrder.find('.icon-shuffle').removeClass('active'); + } + this.#queue.setShuffle(active); + OCA.Music.Storage.set('shuffle', active.toString()); + } + + #setRepeat(active : boolean) : void { + if (active) { + this.#progressAndOrder.find('.icon-repeat').addClass('active'); + } else { + this.#progressAndOrder.find('.icon-repeat').removeClass('active'); + } + this.#queue.setRepeat(active); + OCA.Music.Storage.set('repeat', active.toString()); + } + + #playingRadio() : boolean { + return this.#queue.getCurrentPlaylistId()?.startsWith('radio'); + } + + #getSelectedListId() : string { + let listId = this.#modeSelect.val().toString(); + + this.#filterSelects.forEach((filter) => { + listId += '/' + filter.val(); + }); + + return listId; + } + + #selectListWithId(listId : string, onReady : () => void) : void { + const parts = listId.split('/'); + + const mode = parts.shift(); + this.#modeSelect.val(mode).trigger('change'); + + const handleSelection = () => { + if (parts.length > 0) { + const filterVal = parts.shift(); + this.#events.once('filterPopulated', (filter: JQuery) => { + filter.val(filterVal).trigger('change'); + handleSelection(); + }); + } + else { + this.#events.once('tracksPopulated', onReady); + } + } + handleSelection(); + } + + #scrollToCurrentTrack() : void { + const current = this.#trackList?.find('.current'); + if (current?.length) { + current[0].scrollIntoView({ + behavior: "smooth" + }); + } + else { + this.#selectListWithId(this.#queue.getCurrentPlaylistId(), () => this.#scrollToCurrentTrack()); + } + } +} + +function createSelect(items: any[], placeholder: string|null, onChange: (selectedItem: any) => void, fmtTitle: (item: any) => string = null) : JQuery { + const $select = $('