diff --git a/css/styles.css b/css/styles.css index e553be6..5d2765a 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1983,6 +1983,39 @@ html, body, #app { box-shadow: none; } +/* Sort bar above album/artist grids */ +.library-sort-bar { + display: flex; + gap: 8px; + padding: 16px 28px 8px; + margin-left: 180px; +} + +.library-sort-btn { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-family: inherit; + font-size: 13px; + padding: 6px 14px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.library-sort-btn.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text-primary); +} + +.library-sort-btn.focused { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + color: var(--text-primary); +} + /* Library content area (scrollable, cleared of the sub-nav) */ .library-content { flex: 1; diff --git a/js/api.js b/js/api.js index 4443a48..6e2cb6e 100644 --- a/js/api.js +++ b/js/api.js @@ -237,8 +237,14 @@ var SubsonicAPI = (function() { }); }; - SubsonicAPI.prototype.getStreamUrl = function(songId) { - return this._buildUrl('stream.view', { id: songId }); + SubsonicAPI.prototype.getStreamUrl = function(songId, extraParams) { + var params = { id: songId }; + if (extraParams) { + Object.keys(extraParams).forEach(function(key) { + params[key] = extraParams[key]; + }); + } + return this._buildUrl('stream.view', params); }; SubsonicAPI.prototype.getCoverArtUrl = function(id, size) { @@ -666,6 +672,16 @@ var SubsonicAPI = (function() { }); }; + // --- Top Songs (V3.9) --- + SubsonicAPI.prototype.getTopSongs = function(artist, count) { + var params = { artist: artist }; + if (count) params.count = count; + return this._cachedRequest('getTopSongs.view', params).then(function(data) { + var songs = data && data.topSongs; + return _memoSongList(_ensureArray(songs && songs.song)); + }); + }; + // --- Scrobble (not cached — write operation) --- SubsonicAPI.prototype.scrobble = function(id) { return this._request('scrobble.view', { id: id }); diff --git a/js/player.js b/js/player.js index 1178235..168fcc1 100644 --- a/js/player.js +++ b/js/player.js @@ -206,6 +206,9 @@ var Player = (function() { el._onError = function() { var msg = el.error ? el.error.message : 'Unknown audio error'; error('Player', 'Audio error: ' + msg); + if (typeof App !== 'undefined' && App.showToast) { + App.showToast('Playback error: format not supported'); + } next(); }; @@ -439,6 +442,9 @@ var Player = (function() { }, onerror: function(err) { error('Player', 'AVPlay error: ' + err); + if (typeof App !== 'undefined' && App.showToast) { + App.showToast('Playback error: format not supported'); + } next(); }, onevent: function(eventType, eventData) { @@ -490,7 +496,12 @@ var Player = (function() { error('Player', 'No API instance available'); return; } - streamUrl = api.getStreamUrl(track.id); + var streamParams = null; + if (track.suffix === 'opus' || (track.contentType && track.contentType.indexOf('opus') !== -1)) { + streamParams = { format: 'mp3' }; + log('Player', 'Opus detected, requesting MP3 transcode'); + } + streamUrl = api.getStreamUrl(track.id, streamParams); } log('Player', 'Loading: ' + (track.title || 'Unknown') + ' by ' + (track.artist || 'Unknown')); diff --git a/js/screens/artist.js b/js/screens/artist.js index eb36123..ca99c0c 100644 --- a/js/screens/artist.js +++ b/js/screens/artist.js @@ -11,6 +11,7 @@ var ArtistScreen = (function() { var el = SonanceUtils.el; var log = SonanceUtils.log; var createSvg = SonanceUtils.createSvg; + var createStarSvg = SonanceUtils.createStarSvg; var SVG_PATHS = SonanceUtils.SVG_PATHS; var MAX_PLAY_ALL_ALBUMS = 10; @@ -22,6 +23,7 @@ var ArtistScreen = (function() { var _active = false; var _loadingPlayAll = false; var _stage3Raf = null; // V3-6-fix2 PERF-6: rAF id for deferred discography + var _topSongs = null; // ========================================= // Manual scroll-into-view (Chromium 63 safe) @@ -118,6 +120,12 @@ var ArtistScreen = (function() { if (!artist) { _renderError('Artist not found.'); return; } _artistData = artist; + // V3.9: fetch top songs in parallel with info + var topSongsPromise = api.getTopSongs(artist.name, 10).catch(function(err) { + log('Artist', 'getTopSongs failed: ' + err.message); + return []; + }); + // STAGE 1 — hero + name + action buttons + stub containers. _renderStage1(artist, api); _registerLeftPanelZone(true); @@ -135,7 +143,7 @@ var ArtistScreen = (function() { // instead of the left action panel. NAV-1 snapshot restore // (async poll @50ms) still overrides to the saved index when // the user is backing in from album detail. - var firstAlbum = document.querySelector('#artist-albums-list .focusable'); + var firstAlbum = _container && _container.querySelector('#artist-albums-list .focusable'); if (firstAlbum && FocusManager.getActiveZone() !== 'artist-albums') { FocusManager.setActiveZone('artist-albums', 0, true); } @@ -150,6 +158,15 @@ var ArtistScreen = (function() { _registerArtistContentZones(false); }); + // STAGE 4 — popular songs (V3.9). Renders once getTopSongs + // resolves; appends below discography. + topSongsPromise.then(function(songs) { + if (!_active) return; + _topSongs = songs; + _renderStage4TopSongs(artist, api); + _registerArtistContentZones(false); + }); + log('Artist', 'Stage 1 rendered: ' + (artist.name || 'Unknown') + ' (' + ((artist.album && artist.album.length) || 0) + ' albums)'); }).catch(function(err) { @@ -227,6 +244,7 @@ var ArtistScreen = (function() { // --- RIGHT PANEL — stubs for stages 2 & 3 --- var rightPanel = el('div', { className: 'artist-detail-right' }); rightPanel.appendChild(el('div', { id: 'artist-discography-stub' })); + rightPanel.appendChild(el('div', { id: 'artist-top-songs-stub' })); rightPanel.appendChild(el('div', { id: 'artist-bio-stub' })); rightPanel.appendChild(el('div', { id: 'artist-similar-stub' })); @@ -237,11 +255,13 @@ var ArtistScreen = (function() { } function _renderStage2BioAndSimilar(artist, info, api) { + if (!_container) return; + // Re-render hero photo if Last.fm provided one (swap in place). if (info) { var imageUrl = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null; if (imageUrl) { - var oldWrap = document.getElementById('artist-photo-wrap'); + var oldWrap = _container.querySelector('#artist-photo-wrap'); if (oldWrap && oldWrap.parentNode) { var newWrap = _renderArtistPhoto(artist, info, api); newWrap.id = 'artist-photo-wrap'; @@ -250,14 +270,14 @@ var ArtistScreen = (function() { } } - var bioStub = document.getElementById('artist-bio-stub'); + var bioStub = _container.querySelector('#artist-bio-stub'); if (bioStub) { bioStub.textContent = ''; var bio = _renderBiography(info); if (bio) bioStub.appendChild(bio); } - var similarStub = document.getElementById('artist-similar-stub'); + var similarStub = _container.querySelector('#artist-similar-stub'); if (similarStub) { similarStub.textContent = ''; var similar = _renderSimilarArtists(info, api); @@ -266,12 +286,21 @@ var ArtistScreen = (function() { } function _renderStage3Discography(artist, api) { - var stub = document.getElementById('artist-discography-stub'); + if (!_container) return; + var stub = _container.querySelector('#artist-discography-stub'); if (!stub) return; stub.textContent = ''; stub.appendChild(_renderDiscography(artist, api)); } + function _renderStage4TopSongs(artist, api) { + if (!_container || !_topSongs || !_topSongs.length) return; + var stub = _container.querySelector('#artist-top-songs-stub'); + if (!stub) return; + stub.textContent = ''; + stub.appendChild(_renderPopularSongs(artist, api)); + } + // --- Artist photo (200px circle) --- function _renderArtistPhoto(artist, info, api) { var photo = el('div', { className: 'artist-detail-photo' }); @@ -366,6 +395,64 @@ var ArtistScreen = (function() { return section; } + function _renderPopularSongs(artist, api) { + var songs = _topSongs || []; + if (!songs.length) return null; + + var section = el('div', { className: 'artist-section' }); + section.appendChild(el('div', { className: 'artist-section-label' }, 'POPULAR')); + + var list = el('div', { className: 'artist-top-songs-list', id: 'artist-top-songs-list' }); + + songs.forEach(function(song, index) { + var row = el('div', { + className: 'track-row focusable', + 'data-song-index': String(index), + 'data-song-id': song.id + }); + + row.appendChild(el('div', { className: 'track-row-number' }, + String(index + 1))); + + row.appendChild(el('div', { className: 'track-row-title' }, + song.title || 'Unknown')); + + var trackStar = el('div', { + className: 'track-row-star', + 'data-star-size': '14', + 'data-song-id': song.id + }); + var isStarred = (typeof StarredCache !== 'undefined' && + StarredCache.isSongStarred && StarredCache.isSongStarred(song.id)); + var starIcon = createStarSvg(isStarred); + starIcon.style.width = '14px'; + starIcon.style.height = '14px'; + trackStar.appendChild(starIcon); + if (isStarred) trackStar.classList.add('is-starred'); + row.appendChild(trackStar); + + row.appendChild(el('div', { className: 'track-row-duration' }, + (song._formattedDuration || SonanceUtils.formatDuration(song.duration)))); + + list.appendChild(row); + }); + + // Delegated click handler for the whole list + list.addEventListener('click', function(ev) { + var r = ev.target.closest('.track-row'); + if (!r) return; + var idxStr = r.getAttribute('data-song-index'); + if (idxStr === null) return; + var idx = parseInt(idxStr, 10); + if (isNaN(idx) || !songs[idx]) return; + Player.setQueue(songs, idx); + log('Artist', 'Play popular track ' + (idx + 1) + ': ' + songs[idx].title); + }); + + section.appendChild(list); + return section; + } + // --- Biography section --- function _renderBiography(info) { if (!info) return null; @@ -431,8 +518,8 @@ var ArtistScreen = (function() { // ========================================= function _setPlayAllLoading(isLoading) { - var playBtn = document.getElementById('artist-play-all-btn'); - var shuffleBtn = document.getElementById('artist-shuffle-all-btn'); + var playBtn = _container && _container.querySelector('#artist-play-all-btn'); + var shuffleBtn = _container && _container.querySelector('#artist-shuffle-all-btn'); if (isLoading) { if (playBtn) { playBtn.textContent = 'Loading...'; playBtn.disabled = true; } if (shuffleBtn) { shuffleBtn.textContent = 'Loading...'; shuffleBtn.disabled = true; } @@ -563,9 +650,11 @@ var ArtistScreen = (function() { } function _registerArtistContentZones(setInitialFocus) { - var albumElements = document.querySelectorAll('#artist-albums-list .focusable'); - var similarElements = document.querySelectorAll('#artist-similar-row .focusable'); + var albumElements = _container ? _container.querySelectorAll('#artist-albums-list .focusable') : []; + var topTrackElements = _container ? _container.querySelectorAll('#artist-top-songs-list .focusable') : []; + var similarElements = _container ? _container.querySelectorAll('#artist-similar-row .focusable') : []; var hasAlbums = albumElements.length > 0; + var hasTopSongs = topTrackElements.length > 0; var hasSimilar = similarElements.length > 0; // Re-register left panel with correct neighbours now that we know @@ -576,8 +665,8 @@ var ArtistScreen = (function() { onActivate: function(idx, element) { element.click(); }, neighbors: { left: 'topnav', - right: hasAlbums ? 'artist-albums' : (hasSimilar ? 'artist-similar' : null), - down: hasAlbums ? 'artist-albums' : (hasSimilar ? 'artist-similar' : 'nowplaying-bar') + right: hasAlbums ? 'artist-albums' : (hasTopSongs ? 'artist-top-tracks' : (hasSimilar ? 'artist-similar' : null)), + down: hasAlbums ? 'artist-albums' : (hasTopSongs ? 'artist-top-tracks' : (hasSimilar ? 'artist-similar' : 'nowplaying-bar')) } }); @@ -592,12 +681,34 @@ var ArtistScreen = (function() { element.click(); }, onFocus: function(idx, element) { - var container = document.querySelector('.artist-detail-right'); + var container = _container && _container.querySelector('.artist-detail-right'); _scrollToFocused(container, element); }, neighbors: { left: 'content', up: 'topnav', + down: hasTopSongs ? 'artist-top-tracks' : (hasSimilar ? 'artist-similar' : 'nowplaying-bar') + } + }); + } + + if (hasTopSongs) { + FocusManager.registerZone('artist-top-tracks', { + selector: '#artist-top-songs-list .focusable', + columns: 1, + onActivate: function(idx, element) { + if (typeof App !== 'undefined' && App.saveCurrentFocus) { + App.saveCurrentFocus(); + } + element.click(); + }, + onFocus: function(idx, element) { + var container = _container && _container.querySelector('.artist-detail-right'); + _scrollToFocused(container, element); + }, + neighbors: { + left: 'content', + up: hasAlbums ? 'artist-albums' : 'topnav', down: hasSimilar ? 'artist-similar' : 'nowplaying-bar' } }); @@ -609,12 +720,12 @@ var ArtistScreen = (function() { columns: similarElements.length, onActivate: function(idx, element) { element.click(); }, onFocus: function(idx, element) { - var container = document.querySelector('.artist-detail-right'); + var container = _container && _container.querySelector('.artist-detail-right'); _scrollToFocused(container, element); }, neighbors: { - left: hasAlbums ? null : 'content', - up: hasAlbums ? 'artist-albums' : 'topnav', + left: hasAlbums || hasTopSongs ? null : 'content', + up: hasTopSongs ? 'artist-top-tracks' : (hasAlbums ? 'artist-albums' : 'topnav'), down: 'nowplaying-bar' } }); @@ -629,7 +740,7 @@ var ArtistScreen = (function() { else if (index === 2) Player.next(); }, neighbors: { - up: hasSimilar ? 'artist-similar' : (hasAlbums ? 'artist-albums' : 'content'), + up: hasSimilar ? 'artist-similar' : (hasTopSongs ? 'artist-top-tracks' : (hasAlbums ? 'artist-albums' : 'content')), left: 'topnav' } }); @@ -676,6 +787,7 @@ var ArtistScreen = (function() { _artistId = null; _artistData = null; _artistInfo = null; + _topSongs = null; _loadingPlayAll = false; } diff --git a/js/screens/home.js b/js/screens/home.js index 0576441..94c78fe 100644 --- a/js/screens/home.js +++ b/js/screens/home.js @@ -15,6 +15,7 @@ var HomeScreen = (function() { var _heroAlbum = null; var _newestAlbums = []; var _recentAlbums = []; + var _randomAlbums = []; var _playlists = []; // ========================================= @@ -54,7 +55,7 @@ var HomeScreen = (function() { var newestSection = el('div', { className: 'home-section' }); newestSection.appendChild(el('div', { className: 'home-section-heading' }, 'Recently Added')); var newestRow = el('div', { className: 'home-row', id: 'home-newest-row' }); - newestRow.appendChild(SonanceComponents.renderSkeletonCards(6, 162, 220, 'skeleton-card')); + newestRow.appendChild(SonanceComponents.renderSkeletonCards(9, 162, 220, 'skeleton-card')); newestSection.appendChild(newestRow); wrapper.appendChild(newestSection); @@ -62,10 +63,18 @@ var HomeScreen = (function() { var recentSection = el('div', { className: 'home-section' }); recentSection.appendChild(el('div', { className: 'home-section-heading', id: 'home-recent-heading' }, 'Recently Played')); var recentRow = el('div', { className: 'home-row', id: 'home-recent-row' }); - recentRow.appendChild(SonanceComponents.renderSkeletonCards(6, 162, 220, 'skeleton-card')); + recentRow.appendChild(SonanceComponents.renderSkeletonCards(9, 162, 220, 'skeleton-card')); recentSection.appendChild(recentRow); wrapper.appendChild(recentSection); + // Random Albums section + var randomSection = el('div', { className: 'home-section' }); + randomSection.appendChild(el('div', { className: 'home-section-heading' }, 'Random Albums')); + var randomRow = el('div', { className: 'home-row', id: 'home-random-row' }); + randomRow.appendChild(SonanceComponents.renderSkeletonCards(9, 162, 220, 'skeleton-card')); + randomSection.appendChild(randomRow); + wrapper.appendChild(randomSection); + // Your Playlists section var playlistSection = el('div', { className: 'home-section' }); playlistSection.appendChild(el('div', { className: 'home-section-heading' }, 'Your Playlists')); @@ -81,6 +90,7 @@ var HomeScreen = (function() { // a per-card listener. newestRow.addEventListener('click', _onAlbumRowClick); recentRow.addEventListener('click', _onAlbumRowClick); + randomRow.addEventListener('click', _onAlbumRowClick); playlistRow.addEventListener('click', _onPlaylistRowClick); log('Home', 'Home screen rendered (loading state)'); @@ -118,14 +128,16 @@ var HomeScreen = (function() { var libraryIds = AuthManager.getSelectedLibraries(); // Fetch all data in parallel - var newestPromise = api.getAlbumList2('newest', 6, 0, libraryIds); - var recentPromise = api.getAlbumList2('recent', 6, 0, libraryIds); + var newestPromise = api.getAlbumList2('newest', 9, 0, libraryIds); + var recentPromise = api.getAlbumList2('recent', 9, 0, libraryIds); + var randomPromise = api.getAlbumList2('random', 9, 0, libraryIds); var playlistPromise = api.getPlaylists(); - Promise.all([newestPromise, recentPromise, playlistPromise]).then(function(results) { + Promise.all([newestPromise, recentPromise, randomPromise, playlistPromise]).then(function(results) { _newestAlbums = results[0] || []; _recentAlbums = results[1] || []; - _playlists = results[2] || []; + _randomAlbums = results[2] || []; + _playlists = results[3] || []; // Use first newest album as hero if (_newestAlbums.length > 0) { @@ -137,6 +149,7 @@ var HomeScreen = (function() { _renderHero(api); _renderNewestAlbums(api); _renderRecentAlbums(api); + _renderRandomAlbums(api); _renderPlaylists(api); _registerFocusZones(); @@ -279,6 +292,35 @@ var HomeScreen = (function() { }); } + // ========================================= + // Random Albums Row + // ========================================= + + function _renderRandomAlbums(api) { + var row = document.getElementById('home-random-row'); + if (!row) return; + row.textContent = ''; + + if (!_randomAlbums || _randomAlbums.length === 0) { + row.appendChild(el('div', { className: 'home-empty' }, 'No albums available')); + return; + } + + _randomAlbums.forEach(function(album) { + var card = el('div', { + className: 'album-card focusable', + 'data-album-id': album.id, + 'data-album-title': album.name || album.title || '' + }); + + card.appendChild(SonanceComponents.renderAlbumArt(album, 162, api)); + card.appendChild(el('div', { className: 'album-card-title' }, album.name || album.title || 'Unknown')); + card.appendChild(el('div', { className: 'album-card-artist' }, album.artist || 'Unknown Artist')); + + row.appendChild(card); + }); + } + // ========================================= // Playlists Row // ========================================= @@ -317,14 +359,24 @@ var HomeScreen = (function() { var heroButtons = document.querySelectorAll('#home-hero .focusable'); var newestCards = document.querySelectorAll('#home-newest-row .focusable'); var recentCards = document.querySelectorAll('#home-recent-row .focusable'); + var randomCards = document.querySelectorAll('#home-random-row .focusable'); var playlistCards = document.querySelectorAll('#home-playlists-row .focusable'); // Determine which zones exist for neighbor wiring var hasHero = heroButtons.length > 0; var hasNewest = newestCards.length > 0; var hasRecent = recentCards.length > 0; + var hasRandom = randomCards.length > 0; var hasPlaylists = playlistCards.length > 0; + // Helper: next zone down from current + function nextDown(fromNewest, fromRecent, fromRandom) { + if (fromNewest && hasRecent) return 'home-recent'; + if ((fromNewest || fromRecent) && hasRandom) return 'home-random'; + if ((fromNewest || fromRecent || fromRandom) && hasPlaylists) return 'home-playlists'; + return 'nowplaying-bar'; + } + // Hero buttons zone (registered as 'content' — top nav → down lands here) if (hasHero) { FocusManager.registerZone('content', { @@ -334,7 +386,7 @@ var HomeScreen = (function() { onFocus: function(idx, element) { _scrollToFocused(element); }, neighbors: { left: 'topnav', - down: hasNewest ? 'home-newest' : (hasRecent ? 'home-recent' : (hasPlaylists ? 'home-playlists' : 'nowplaying-bar')) + down: nextDown(true, false, false) } }); } @@ -349,7 +401,7 @@ var HomeScreen = (function() { neighbors: { left: 'topnav', up: hasHero ? 'content' : 'topnav', - down: hasRecent ? 'home-recent' : (hasPlaylists ? 'home-playlists' : 'nowplaying-bar') + down: nextDown(true, false, false) } }); @@ -362,7 +414,7 @@ var HomeScreen = (function() { onFocus: function(idx, element) { _scrollToFocused(element); }, neighbors: { left: 'topnav', - down: hasRecent ? 'home-recent' : (hasPlaylists ? 'home-playlists' : 'nowplaying-bar') + down: nextDown(true, false, false) } }); } @@ -378,7 +430,7 @@ var HomeScreen = (function() { neighbors: { left: 'topnav', up: hasNewest ? 'home-newest' : (hasHero ? 'content' : 'topnav'), - down: hasPlaylists ? 'home-playlists' : 'nowplaying-bar' + down: nextDown(false, true, false) } }); @@ -389,6 +441,35 @@ var HomeScreen = (function() { columns: recentCards.length, onActivate: function(idx, element) { element.click(); }, onFocus: function(idx, element) { _scrollToFocused(element); }, + neighbors: { + left: 'topnav', + down: nextDown(false, true, false) + } + }); + } + } + + // Random Albums zone + if (hasRandom) { + FocusManager.registerZone('home-random', { + selector: '#home-random-row .focusable', + columns: randomCards.length, + onActivate: function(idx, element) { element.click(); }, + onFocus: function(idx, element) { _scrollToFocused(element); }, + neighbors: { + left: 'topnav', + up: hasRecent ? 'home-recent' : (hasNewest ? 'home-newest' : (hasHero ? 'content' : 'topnav')), + down: hasPlaylists ? 'home-playlists' : 'nowplaying-bar' + } + }); + + // If no hero, newest, or recent, register random as 'content' + if (!hasHero && !hasNewest && !hasRecent) { + FocusManager.registerZone('content', { + selector: '#home-random-row .focusable', + columns: randomCards.length, + onActivate: function(idx, element) { element.click(); }, + onFocus: function(idx, element) { _scrollToFocused(element); }, neighbors: { left: 'topnav', down: hasPlaylists ? 'home-playlists' : 'nowplaying-bar' @@ -406,7 +487,7 @@ var HomeScreen = (function() { onFocus: function(idx, element) { _scrollToFocused(element); }, neighbors: { left: 'topnav', - up: hasRecent ? 'home-recent' : (hasNewest ? 'home-newest' : (hasHero ? 'content' : 'topnav')), + up: hasRandom ? 'home-random' : (hasRecent ? 'home-recent' : (hasNewest ? 'home-newest' : (hasHero ? 'content' : 'topnav'))), down: 'nowplaying-bar' } }); @@ -415,6 +496,7 @@ var HomeScreen = (function() { // Update NP bar to point up to last content zone var lastZone = 'content'; if (hasPlaylists) lastZone = 'home-playlists'; + else if (hasRandom) lastZone = 'home-random'; else if (hasRecent) lastZone = 'home-recent'; else if (hasNewest) lastZone = 'home-newest'; diff --git a/js/screens/library.js b/js/screens/library.js index 63dd1b2..a7078cf 100644 --- a/js/screens/library.js +++ b/js/screens/library.js @@ -27,6 +27,10 @@ var LibraryScreen = (function() { var ARTIST_ITEM_HEIGHT = 180; // px — 100 avatar + name + count + 8px×2 padding + 24px row gap var ARTIST_ITEM_MIN_WIDTH = 130; + // Sort state + var _albumSort = 'alphabeticalByName'; // 'alphabeticalByName' | 'random' + var _artistSort = 'name'; // 'name' | 'albums' | 'random' + // V3-3 vertical sub-nav var LIBRARY_TABS = [ { key: 'albums', label: 'Albums' }, @@ -108,6 +112,73 @@ var LibraryScreen = (function() { } } + // ========================================= + // Sort Bars + // ========================================= + + function _renderAlbumSortBar() { + var bar = el('div', { className: 'library-sort-bar', id: 'album-sort-bar' }); + var sorts = [ + { key: 'alphabeticalByName', label: 'A-Z' }, + { key: 'newest', label: 'Recent' }, + { key: 'random', label: 'Random' } + ]; + sorts.forEach(function(s) { + var btn = el('button', { + className: 'library-sort-btn focusable' + (_albumSort === s.key ? ' active' : '') + }, s.label); + btn.addEventListener('click', function() { + if (_albumSort === s.key) return; + _albumSort = s.key; + _switchTabInstant('albums'); + }); + bar.appendChild(btn); + }); + return bar; + } + + function _renderArtistSortBar() { + var bar = el('div', { className: 'library-sort-bar', id: 'artist-sort-bar' }); + var sorts = [ + { key: 'name', label: 'A-Z' }, + { key: 'albums', label: 'Albums' }, + { key: 'random', label: 'Random' } + ]; + sorts.forEach(function(s) { + var btn = el('button', { + className: 'library-sort-btn focusable' + (_artistSort === s.key ? ' active' : '') + }, s.label); + btn.addEventListener('click', function() { + if (_artistSort === s.key) return; + _artistSort = s.key; + _switchTabInstant('artists'); + }); + bar.appendChild(btn); + }); + return bar; + } + + function _sortArtists(artists, sortType) { + if (sortType === 'albums') { + return artists.slice().sort(function(a, b) { + var ac = (b && b.albumCount) || 0; + var bc = (a && a.albumCount) || 0; + if (ac !== bc) return ac - bc; + return ((a && a.name) || '').localeCompare((b && b.name) || ''); + }); + } else if (sortType === 'random') { + var shuffled = artists.slice(); + for (var i = shuffled.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var t = shuffled[i]; + shuffled[i] = shuffled[j]; + shuffled[j] = t; + } + return shuffled; + } + return artists; + } + // ========================================= // Scroll Helper (Chromium 63 safe) // ========================================= @@ -464,7 +535,7 @@ var LibraryScreen = (function() { function fetchPage(count, loaderOffset) { if (!multi) { return api.getAlbumList2( - 'alphabeticalByName', count, loaderOffset, libraryIds + _albumSort, count, loaderOffset, libraryIds ); } // Refill from upstream until `count` fresh items have been @@ -475,7 +546,7 @@ var LibraryScreen = (function() { return Promise.resolve(collected.slice(0, count)); } return api.getAlbumList2( - 'alphabeticalByName', count, apiOffset, libraryIds + _albumSort, count, apiOffset, libraryIds ).then(function(albums) { apiOffset += count; if (!albums.length) { @@ -509,6 +580,18 @@ var LibraryScreen = (function() { } if (!_contentContainer) return; _contentContainer.textContent = ''; + _contentContainer.appendChild(_renderAlbumSortBar()); + + FocusManager.registerZone('library-sort', { + selector: '#album-sort-bar .focusable', + columns: 3, + onActivate: function(idx, element) { element.click(); }, + neighbors: { + left: 'library-subnav', + up: 'topnav', + down: 'library-grid' + } + }); if (albums.length === 0) { _renderEmpty('No albums found'); @@ -603,9 +686,9 @@ var LibraryScreen = (function() { } }, neighbors: { - /* V3-6-fix NAV-2: Up goes to top nav, Left enters side sub-nav. */ + /* V3-6-fix NAV-2: Up goes to sort bar, Left enters side sub-nav. */ left: 'library-subnav', - up: 'topnav', + up: 'library-sort', down: 'nowplaying-bar' } }); @@ -638,7 +721,8 @@ var LibraryScreen = (function() { log('Library', 'Stale artists response ignored (active=' + _activeTab + ')'); return; } - _renderArtists(artists || [], api); + artists = _sortArtists(artists || [], _artistSort); + _renderArtists(artists, api); }).catch(function(err) { if (_activeTab !== expected) return; log('Library', 'Error loading artists: ' + err.message); @@ -668,6 +752,18 @@ var LibraryScreen = (function() { function _renderArtists(artists, api) { if (!_contentContainer) return; _contentContainer.textContent = ''; + _contentContainer.appendChild(_renderArtistSortBar()); + + FocusManager.registerZone('library-sort', { + selector: '#artist-sort-bar .focusable', + columns: 3, + onActivate: function(idx, element) { element.click(); }, + neighbors: { + left: 'library-subnav', + up: 'topnav', + down: 'library-grid' + } + }); if (artists.length === 0) { _renderEmpty('No artists found'); @@ -714,7 +810,7 @@ var LibraryScreen = (function() { _artistsChunkRaf = requestAnimationFrame(appendChunk); } else if (!_artistsChunkedZoneRegistered) { var artCols = _getGridColumnCount(grid) || 6; - _registerGridZone(artCols); + _registerGridZone(artCols, 'library-sort'); _artistsChunkedZoneRegistered = true; } } @@ -809,7 +905,7 @@ var LibraryScreen = (function() { }, neighbors: { left: 'library-subnav', - up: 'topnav', + up: 'library-sort', down: 'nowplaying-bar' } }); @@ -1100,7 +1196,7 @@ var LibraryScreen = (function() { // Focus Zone Registration (non-albums) // ========================================= - function _registerGridZone(cols) { + function _registerGridZone(cols, upNeighbor) { var zoneConfig = { selector: '#library-grid .focusable', columns: cols, @@ -1116,9 +1212,9 @@ var LibraryScreen = (function() { _scrollToFocused(_getScrollContainer(), element); }, neighbors: { - /* V3-6-fix NAV-2: Up goes to top nav, Left enters side sub-nav. */ + /* V3-6-fix NAV-2: Up goes to sort bar or top nav, Left enters side sub-nav. */ left: 'library-subnav', - up: 'topnav', + up: upNeighbor || 'topnav', down: 'nowplaying-bar' } }; diff --git a/tests/mock-boot.js b/tests/mock-boot.js index db08e65..429a4db 100644 --- a/tests/mock-boot.js +++ b/tests/mock-boot.js @@ -87,6 +87,7 @@ if (/star\.view|unstar\.view|scrobble\.view/.test(u)) return resp({}); if (/getArtistInfo2/.test(u)) return resp({ artistInfo2: {} }); if (/getMusicFolders/.test(u)) return resp({ musicFolders: { musicFolder: [] } }); + if (/getTopSongs/.test(u)) return resp({ topSongs: { song: mockSongs.slice(0, 10) } }); if (/getIndexes/.test(u)) return resp({ indexes: { index: [], lastModified: 0, ignoredArticles: 'The' } }); return resp({}); };