infinite track playing for popular tracks

This commit is contained in:
edidealt 2026-03-21 23:24:09 +00:00
parent e69c32949b
commit 6f8b479d0f
6 changed files with 245 additions and 17 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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();
}

View file

@ -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);

View file

@ -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);

View file

@ -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');