From 6f8b479d0f825c2e0140b2929f690c83304376fe Mon Sep 17 00:00:00 2001 From: edidealt Date: Sat, 21 Mar 2026 23:24:09 +0000 Subject: [PATCH] infinite track playing for popular tracks --- js/HiFi.ts | 28 +++++++---- js/api.js | 83 +++++++++++++++++++++++++++++++ js/events.js | 11 +++++ js/music-api.js | 4 ++ js/player.js | 128 +++++++++++++++++++++++++++++++++++++++++++++--- js/ui.js | 8 +++ 6 files changed, 245 insertions(+), 17 deletions(-) diff --git a/js/HiFi.ts b/js/HiFi.ts index 7db70ac..78c5a92 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -315,7 +315,13 @@ export class HiFiClient { return { version: API_VERSION, albums: (payload?.data || []).map(resolveAlbum) }; } - async getArtist(id?: number | null, f?: number | null, skip_tracks = false, signal?: AbortSignal) { + async getArtist( + id?: number | null, + f?: number | null, + skip_tracks = false, + signal?: AbortSignal, + options?: { offset?: number; limit?: number } + ) { if (!id && !f) throw new ResponseError(400, 'Provide id or f query param'); if (id) { @@ -352,13 +358,13 @@ export class HiFiClient { ]; if (skip_tracks) { - tasks.push( - this.fetchJson( - `https://api.tidal.com/v1/artists/${f}/toptracks`, - { countryCode: this.countryCode, limit: 15 }, - signal - ) - ); + const offset = options?.offset; + const limit = options?.limit; + const toptracks_params: Params = { countryCode: this.countryCode, limit: limit || 15 }; + if (offset !== undefined) { + toptracks_params.offset = offset; + } + tasks.push(this.fetchJson(`https://api.tidal.com/v1/artists/${f}/toptracks`, toptracks_params, signal)); } const results = await Promise.all(tasks.map((p) => p.catch((e) => e))); @@ -702,7 +708,11 @@ export class HiFiClient { qp.id ? Number(qp.id) : undefined, qp.f ? Number(qp.f) : undefined, qp.skip_tracks === 'true' || qp.skip_tracks === '1' || qp.skip_tracks === 'True', - signal + signal, + { + offset: qp.offset !== undefined ? Number(qp.offset) : undefined, + limit: qp.limit !== undefined ? Number(qp.limit) : undefined, + } ); case '/cover': return await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal); diff --git a/js/api.js b/js/api.js index 3186199..cf52e8d 100644 --- a/js/api.js +++ b/js/api.js @@ -1010,6 +1010,89 @@ export class LosslessAPI { return result; } + async getArtistTopTracks(artistId, options = {}) { + const offset = options.offset || 0; + const limit = options.limit || 15; + console.log('[getArtistTopTracks] Called:', { artistId, offset, limit, options }); + + const cacheKey = `artist_tracks_${artistId}_${offset}_${limit}`; + if (!options.skipCache) { + const cached = await this.cache.get('artist', cacheKey); + if (cached) return cached; + } + + try { + // Use f parameter with skip_tracks=true to get toptracks from the dedicated endpoint + const response = await this.fetchWithRetry( + `/artist/?f=${artistId}&skip_tracks=true&offset=${offset}&limit=${limit}` + ); + const jsonData = await response.json(); + + let data = jsonData.data || jsonData; + console.log( + '[getArtistTopTracks] Raw response data keys:', + Object.keys(data), + 'tracks:', + data.tracks?.length + ); + + // Extract tracks from the response + let tracks = []; + + // Check for tracks array directly (from toptracks endpoint) + if (Array.isArray(data.tracks)) { + tracks = data.tracks; + } + + // Also scan for tracks in the data structure + if (tracks.length === 0) { + const trackMap = new Map(); + const isTrack = (v) => v?.id && v.duration; + + const scan = (value, visited) => { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + + if (Array.isArray(value)) { + value.forEach((item) => scan(item, visited)); + return; + } + + const item = value.item || value; + if (isTrack(item)) { + trackMap.set(item.id, this.prepareTrack(item)); + } + + Object.values(value).forEach((nested) => scan(nested, visited)); + }; + + const visited = new Set(); + scan(data, visited); + tracks = Array.from(trackMap.values()); + } + + tracks = tracks.map((t) => this.prepareTrack(t)).sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); + tracks = await this.enrichTracksWithAlbumDates(tracks); + + // Safeguard: If API ignores offset, it returns the same first tracks + const hasMore = tracks.length === limit && (offset === 0 || tracks[0]?.id !== options.firstTrackId); + const result = { + tracks, + offset, + limit, + hasMore, + }; + + if (!(response instanceof TidalResponse)) { + await this.cache.set('artist', cacheKey, result); + } + return result; + } catch (e) { + console.warn('Failed to fetch artist top tracks:', e); + return { tracks: [], offset, limit, hasMore: false }; + } + } + async getSimilarArtists(artistId) { const cached = await this.cache.get('similar_artists', artistId); if (cached) return cached; diff --git a/js/events.js b/js/events.js index 7d60b6a..9f38c8c 100644 --- a/js/events.js +++ b/js/events.js @@ -2183,6 +2183,17 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen const startIndex = trackList.findIndex((t) => t.id == clickedTrackId); player.setQueue(trackList, startIndex); + + // Set artist popular tracks context if on artist page + console.log('[Events] Setting context:', { + page: ui.currentPage, + artistId: ui.currentArtistId, + trackCount: trackList.length, + }); + if (ui.currentPage === 'artist' && ui.currentArtistId) { + player.setArtistPopularTracksContext(ui.currentArtistId, trackList, trackList.length, true); + } + document.getElementById('shuffle-btn').classList.remove('active'); player.playTrackFromQueue(); } diff --git a/js/music-api.js b/js/music-api.js index 221c2f5..52f8520 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -234,6 +234,10 @@ export class MusicAPI { return api.getSimilarArtists(cleanId); } + async getArtistTopTracks(artistId, options = {}) { + return this.tidalAPI.getArtistTopTracks(artistId, options); + } + async getSimilarAlbums(albumId) { const provider = this.getProviderFromId(albumId) || this.getCurrentProvider(); const api = this.getAPI(provider); diff --git a/js/player.js b/js/player.js index 94e12bb..4be397f 100644 --- a/js/player.js +++ b/js/player.js @@ -53,6 +53,14 @@ export class Player { this.sleepTimer = null; this.sleepTimerEndTime = null; this.sleepTimerInterval = null; + // Artist popular tracks state + this.artistPopularTracksState = { + artistId: null, + offset: 0, + initialTracks: [], + isFetching: false, + hasMore: true, + }; } async init() { @@ -613,6 +621,33 @@ export class Player { return; } + // Proactively fetch more artist tracks when the last track starts playing + console.log('[playTrackFromQueue] Check for fetch:', { + radioEnabled: this.radioEnabled, + artistId: this.artistPopularTracksState.artistId, + hasMore: this.artistPopularTracksState.hasMore, + isFetching: this.artistPopularTracksState.isFetching, + currentIndex: this.currentQueueIndex, + queueLength: currentQueue.length, + isLastTrack: this.currentQueueIndex >= currentQueue.length - 1, + }); + + if ( + !this.radioEnabled && + this.artistPopularTracksState.artistId && + this.artistPopularTracksState.hasMore && + !this.artistPopularTracksState.isFetching && + this.currentQueueIndex >= currentQueue.length - 1 + ) { + console.log('[playTrackFromQueue] Fetching more tracks!'); + this.fetchMoreArtistPopularTracks().then((newTracks) => { + console.log('[playTrackFromQueue] Got tracks:', newTracks?.length); + if (newTracks && newTracks.length > 0) { + this.addToQueue(newTracks); + } + }); + } + this.saveQueueState(); this.currentTrack = track; @@ -944,10 +979,6 @@ export class Player { const currentQueue = this.getCurrentQueue(); const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1; - if (this.radioEnabled && this.currentQueueIndex >= currentQueue.length - 3) { - this.fetchRadioRecommendations(); - } - if (recursiveCount > currentQueue.length) { if (this.radioEnabled && isLastTrack) { this.fetchRadioRecommendations().then(() => { @@ -958,12 +989,21 @@ export class Player { }); return; } - console.error('All tracks in queue are unavailable or blocked.'); + if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { + this.fetchMoreArtistPopularTracks().then((newTracks) => { + if (newTracks && newTracks.length > 0) { + this.addToQueue(newTracks); + this.playNext(0); + } else { + this.activeElement.pause(); + } + }); + return; + } this.activeElement.pause(); return; } - // Import blocking settings dynamically import('./storage.js').then(({ contentBlockingSettings }) => { if ( this.repeatMode === REPEAT_MODE.ONE && @@ -977,7 +1017,6 @@ export class Player { if (!isLastTrack) { this.currentQueueIndex++; const track = currentQueue[this.currentQueueIndex]; - // Skip unavailable and blocked tracks if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { return this.playNext(recursiveCount + 1); } @@ -989,10 +1028,19 @@ export class Player { } }); return; + } else if (this.artistPopularTracksState.artistId && this.artistPopularTracksState.hasMore) { + this.fetchMoreArtistPopularTracks().then((newTracks) => { + if (newTracks && newTracks.length > 0) { + this.addToQueue(newTracks); + } + // Now play the next track (which is now at currentQueueIndex + 1 if tracks were added) + this.currentQueueIndex++; + this.playTrackFromQueue(0, recursiveCount); + }); + return; } else if (this.repeatMode === REPEAT_MODE.ALL) { this.currentQueueIndex = 0; const track = currentQueue[this.currentQueueIndex]; - // Skip unavailable and blocked tracks if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) { return this.playNext(recursiveCount + 1); } @@ -1276,6 +1324,70 @@ export class Player { this.saveQueueState(); } + setArtistPopularTracksContext(artistId, initialTracks, offset = 15, hasMore = true) { + this.artistPopularTracksState = { + artistId, + offset, + initialTracks, + isFetching: false, + hasMore, + }; + } + + clearArtistPopularTracksContext() { + this.artistPopularTracksState = { + artistId: null, + offset: 0, + initialTracks: [], + isFetching: false, + hasMore: false, + }; + } + + async fetchMoreArtistPopularTracks() { + const state = this.artistPopularTracksState; + console.log('[fetchMoreArtistPopularTracks] Called:', { + artistId: state.artistId, + offset: state.offset, + isFetching: state.isFetching, + hasMore: state.hasMore, + }); + + if (!state.artistId || state.isFetching || !state.hasMore) { + console.log('[fetchMoreArtistPopularTracks] Early return'); + return []; + } + + state.isFetching = true; + + try { + console.log('[fetchMoreArtistPopularTracks] Fetching with offset:', state.offset); + const result = await this.api.getArtistTopTracks(state.artistId, { + offset: state.offset, + limit: 15, + firstTrackId: state.initialTracks[0]?.id, + }); + + console.log('[fetchMoreArtistPopularTracks] Result:', result); + + if (result.tracks && result.tracks.length > 0) { + state.offset += result.tracks.length; + state.hasMore = result.hasMore; + + return result.tracks; + } else { + state.hasMore = false; + return []; + } + } catch (error) { + console.warn('Failed to fetch more artist popular tracks:', error); + state.hasMore = false; + return []; + } finally { + state.isFetching = false; + } + } + addToQueue(trackOrTracks) { const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks]; this.queue.push(...tracks); diff --git a/js/ui.js b/js/ui.js index 7e3646a..3cd1302 100644 --- a/js/ui.js +++ b/js/ui.js @@ -126,6 +126,7 @@ export class UIRenderer { this.visualizer = null; this.renderLock = false; this.lastRecommendedTracks = []; + this.currentArtistId = null; // Listen for dynamic color reset events window.addEventListener('reset-dynamic-color', () => { @@ -1629,6 +1630,12 @@ export class UIRenderer { document.querySelector('.main-content').scrollTop = 0; + // Clear artist context when navigating away from artist page + if (pageId !== 'artist') { + this.currentArtistId = null; + this.player.clearArtistPopularTracksContext(); + } + // Clear background and color if not on album, artist, playlist, or mix page if (!['album', 'artist', 'playlist', 'mix'].includes(pageId)) { this.setPageBackground(null); @@ -3934,6 +3941,7 @@ export class UIRenderer { async renderArtistPage(artistId, provider = null) { this.showPage('artist'); + this.currentArtistId = artistId; const imageEl = document.getElementById('artist-detail-image'); const nameEl = document.getElementById('artist-detail-name');