infinite track playing for popular tracks
This commit is contained in:
parent
e69c32949b
commit
6f8b479d0f
6 changed files with 245 additions and 17 deletions
28
js/HiFi.ts
28
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);
|
||||
|
|
|
|||
83
js/api.js
83
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;
|
||||
|
|
|
|||
11
js/events.js
11
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
128
js/player.js
128
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);
|
||||
|
|
|
|||
8
js/ui.js
8
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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue