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) };
|
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 && !f) throw new ResponseError(400, 'Provide id or f query param');
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -352,13 +358,13 @@ export class HiFiClient {
|
||||||
];
|
];
|
||||||
|
|
||||||
if (skip_tracks) {
|
if (skip_tracks) {
|
||||||
tasks.push(
|
const offset = options?.offset;
|
||||||
this.fetchJson(
|
const limit = options?.limit;
|
||||||
`https://api.tidal.com/v1/artists/${f}/toptracks`,
|
const toptracks_params: Params = { countryCode: this.countryCode, limit: limit || 15 };
|
||||||
{ countryCode: this.countryCode, limit: 15 },
|
if (offset !== undefined) {
|
||||||
signal
|
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)));
|
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.id ? Number(qp.id) : undefined,
|
||||||
qp.f ? Number(qp.f) : undefined,
|
qp.f ? Number(qp.f) : undefined,
|
||||||
qp.skip_tracks === 'true' || qp.skip_tracks === '1' || qp.skip_tracks === 'True',
|
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':
|
case '/cover':
|
||||||
return await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal);
|
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;
|
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) {
|
async getSimilarArtists(artistId) {
|
||||||
const cached = await this.cache.get('similar_artists', artistId);
|
const cached = await this.cache.get('similar_artists', artistId);
|
||||||
if (cached) return cached;
|
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);
|
const startIndex = trackList.findIndex((t) => t.id == clickedTrackId);
|
||||||
|
|
||||||
player.setQueue(trackList, startIndex);
|
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');
|
document.getElementById('shuffle-btn').classList.remove('active');
|
||||||
player.playTrackFromQueue();
|
player.playTrackFromQueue();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,10 @@ export class MusicAPI {
|
||||||
return api.getSimilarArtists(cleanId);
|
return api.getSimilarArtists(cleanId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistTopTracks(artistId, options = {}) {
|
||||||
|
return this.tidalAPI.getArtistTopTracks(artistId, options);
|
||||||
|
}
|
||||||
|
|
||||||
async getSimilarAlbums(albumId) {
|
async getSimilarAlbums(albumId) {
|
||||||
const provider = this.getProviderFromId(albumId) || this.getCurrentProvider();
|
const provider = this.getProviderFromId(albumId) || this.getCurrentProvider();
|
||||||
const api = this.getAPI(provider);
|
const api = this.getAPI(provider);
|
||||||
|
|
|
||||||
128
js/player.js
128
js/player.js
|
|
@ -53,6 +53,14 @@ export class Player {
|
||||||
this.sleepTimer = null;
|
this.sleepTimer = null;
|
||||||
this.sleepTimerEndTime = null;
|
this.sleepTimerEndTime = null;
|
||||||
this.sleepTimerInterval = null;
|
this.sleepTimerInterval = null;
|
||||||
|
// Artist popular tracks state
|
||||||
|
this.artistPopularTracksState = {
|
||||||
|
artistId: null,
|
||||||
|
offset: 0,
|
||||||
|
initialTracks: [],
|
||||||
|
isFetching: false,
|
||||||
|
hasMore: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
@ -613,6 +621,33 @@ export class Player {
|
||||||
return;
|
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.saveQueueState();
|
||||||
|
|
||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
|
|
@ -944,10 +979,6 @@ export class Player {
|
||||||
const currentQueue = this.getCurrentQueue();
|
const currentQueue = this.getCurrentQueue();
|
||||||
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
|
const isLastTrack = this.currentQueueIndex >= currentQueue.length - 1;
|
||||||
|
|
||||||
if (this.radioEnabled && this.currentQueueIndex >= currentQueue.length - 3) {
|
|
||||||
this.fetchRadioRecommendations();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recursiveCount > currentQueue.length) {
|
if (recursiveCount > currentQueue.length) {
|
||||||
if (this.radioEnabled && isLastTrack) {
|
if (this.radioEnabled && isLastTrack) {
|
||||||
this.fetchRadioRecommendations().then(() => {
|
this.fetchRadioRecommendations().then(() => {
|
||||||
|
|
@ -958,12 +989,21 @@ export class Player {
|
||||||
});
|
});
|
||||||
return;
|
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();
|
this.activeElement.pause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import blocking settings dynamically
|
|
||||||
import('./storage.js').then(({ contentBlockingSettings }) => {
|
import('./storage.js').then(({ contentBlockingSettings }) => {
|
||||||
if (
|
if (
|
||||||
this.repeatMode === REPEAT_MODE.ONE &&
|
this.repeatMode === REPEAT_MODE.ONE &&
|
||||||
|
|
@ -977,7 +1017,6 @@ export class Player {
|
||||||
if (!isLastTrack) {
|
if (!isLastTrack) {
|
||||||
this.currentQueueIndex++;
|
this.currentQueueIndex++;
|
||||||
const track = currentQueue[this.currentQueueIndex];
|
const track = currentQueue[this.currentQueueIndex];
|
||||||
// Skip unavailable and blocked tracks
|
|
||||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||||
return this.playNext(recursiveCount + 1);
|
return this.playNext(recursiveCount + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -989,10 +1028,19 @@ export class Player {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
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) {
|
} else if (this.repeatMode === REPEAT_MODE.ALL) {
|
||||||
this.currentQueueIndex = 0;
|
this.currentQueueIndex = 0;
|
||||||
const track = currentQueue[this.currentQueueIndex];
|
const track = currentQueue[this.currentQueueIndex];
|
||||||
// Skip unavailable and blocked tracks
|
|
||||||
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
if (track?.isUnavailable || contentBlockingSettings.shouldHideTrack(track)) {
|
||||||
return this.playNext(recursiveCount + 1);
|
return this.playNext(recursiveCount + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -1276,6 +1324,70 @@ export class Player {
|
||||||
this.saveQueueState();
|
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) {
|
addToQueue(trackOrTracks) {
|
||||||
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
|
const tracks = Array.isArray(trackOrTracks) ? trackOrTracks : [trackOrTracks];
|
||||||
this.queue.push(...tracks);
|
this.queue.push(...tracks);
|
||||||
|
|
|
||||||
8
js/ui.js
8
js/ui.js
|
|
@ -126,6 +126,7 @@ export class UIRenderer {
|
||||||
this.visualizer = null;
|
this.visualizer = null;
|
||||||
this.renderLock = false;
|
this.renderLock = false;
|
||||||
this.lastRecommendedTracks = [];
|
this.lastRecommendedTracks = [];
|
||||||
|
this.currentArtistId = null;
|
||||||
|
|
||||||
// Listen for dynamic color reset events
|
// Listen for dynamic color reset events
|
||||||
window.addEventListener('reset-dynamic-color', () => {
|
window.addEventListener('reset-dynamic-color', () => {
|
||||||
|
|
@ -1629,6 +1630,12 @@ export class UIRenderer {
|
||||||
|
|
||||||
document.querySelector('.main-content').scrollTop = 0;
|
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
|
// Clear background and color if not on album, artist, playlist, or mix page
|
||||||
if (!['album', 'artist', 'playlist', 'mix'].includes(pageId)) {
|
if (!['album', 'artist', 'playlist', 'mix'].includes(pageId)) {
|
||||||
this.setPageBackground(null);
|
this.setPageBackground(null);
|
||||||
|
|
@ -3934,6 +3941,7 @@ export class UIRenderer {
|
||||||
|
|
||||||
async renderArtistPage(artistId, provider = null) {
|
async renderArtistPage(artistId, provider = null) {
|
||||||
this.showPage('artist');
|
this.showPage('artist');
|
||||||
|
this.currentArtistId = artistId;
|
||||||
|
|
||||||
const imageEl = document.getElementById('artist-detail-image');
|
const imageEl = document.getElementById('artist-detail-image');
|
||||||
const nameEl = document.getElementById('artist-detail-name');
|
const nameEl = document.getElementById('artist-detail-name');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue