mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(nowPlaying): co-locate now playing feature into features/nowPlaying
This commit is contained in:
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* Regression tests for the id-gating tuple pattern in `useNowPlayingFetchers`.
|
||||
*
|
||||
* Each id-keyed slot (`artistInfo`, `songMeta`, `albumData`, `discography`) is
|
||||
* held as a `{ id, value }` tuple internally and gated on id-match at the
|
||||
* return statement. This guarantees that consumers building a `cacheKey` from
|
||||
* the current id can never receive a value paired with a previously-current
|
||||
* id — the bug that PR #732 fixed inside `NowPlayingInfo.tsx` and that this
|
||||
* hook would otherwise leak into every other consumer (e.g. ArtistCard on the
|
||||
* NowPlaying page).
|
||||
*/
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
|
||||
|
||||
vi.mock('../api/subsonicArtists');
|
||||
vi.mock('../api/subsonicLibrary');
|
||||
vi.mock('../api/bandsintown');
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
||||
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
||||
import { fetchBandsintownEvents } from '../api/bandsintown';
|
||||
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
|
||||
|
||||
// Resolved return shapes of the mocked API calls — used to cast deliberately
|
||||
// partial test fixtures without `any`.
|
||||
type ArtistForServer = Awaited<ReturnType<typeof getArtistForServer>>;
|
||||
type AlbumForServer = Awaited<ReturnType<typeof getAlbumForServer>>;
|
||||
|
||||
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
|
||||
// the hook treats `null` as the "no info available" case and stores it as
|
||||
// such in its tuple. The tests mirror that — cast to a nullable-returning
|
||||
// shape so we can mock the empty case without `as any` at every site.
|
||||
const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as {
|
||||
mockImplementation: (impl: (serverId: string, id: string) => Promise<SubsonicArtistInfo | null>) => void;
|
||||
mockResolvedValue: (v: SubsonicArtistInfo | null) => void;
|
||||
};
|
||||
|
||||
const baseDeps: NowPlayingFetchersDeps = {
|
||||
songId: undefined,
|
||||
artistId: undefined,
|
||||
albumId: undefined,
|
||||
artistName: '',
|
||||
enableBandsintown: false,
|
||||
audiomuseNavidromeEnabled: false,
|
||||
enrichmentKey: '',
|
||||
currentTrack: null,
|
||||
subsonicServerId: 'srv1',
|
||||
fetchEnabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as unknown as ArtistForServer);
|
||||
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/** Deferred promise helper — lets a test step the fetch resolution manually. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('useNowPlayingFetchers — id-gated artistInfo', () => {
|
||||
it('returns null artistInfo while the previously-resolved info belongs to a different artistId', async () => {
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
const b = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (_sid, id) => {
|
||||
if (id === 'art-A') return a.promise;
|
||||
if (id === 'art-B') return b.promise;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ artistId }: { artistId: string }) =>
|
||||
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
||||
{ initialProps: { artistId: 'art-A' } },
|
||||
);
|
||||
|
||||
// Before any resolve, no info yet.
|
||||
expect(result.current.artistInfo).toBeNull();
|
||||
|
||||
// Resolve A — artistInfo becomes A's info.
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => {
|
||||
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'A.jpg' });
|
||||
});
|
||||
|
||||
// Switch to artistId B. Without id-gating, artistInfo would still be A's
|
||||
// info paired with the new B id — the bug that PR #732 fixed in the queue
|
||||
// info panel. With gating, artistInfo flips to null immediately.
|
||||
rerender({ artistId: 'art-B' });
|
||||
expect(result.current.artistInfo).toBeNull();
|
||||
|
||||
// Resolve B — artistInfo now becomes B's info, never paired with A.
|
||||
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => {
|
||||
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak a late-arriving resolve for a stale artistId', async () => {
|
||||
// Race: artist A's fetch resolves AFTER the consumer switched to B.
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (_sid, id) => {
|
||||
if (id === 'art-A') return a.promise;
|
||||
if (id === 'art-B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ artistId }: { artistId: string }) =>
|
||||
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
||||
{ initialProps: { artistId: 'art-A' } },
|
||||
);
|
||||
|
||||
// Switch to B before A resolves.
|
||||
rerender({ artistId: 'art-B' });
|
||||
await waitFor(() => {
|
||||
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
||||
});
|
||||
|
||||
// Late A resolve must not overwrite the displayed value for B.
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography', () => {
|
||||
it('gates songMeta on songId match', async () => {
|
||||
const s1 = deferred<SubsonicSong | null>();
|
||||
const s2 = deferred<SubsonicSong | null>();
|
||||
vi.mocked(getSongForServer).mockImplementation(async (_sid, id) => {
|
||||
if (id === 's1') return s1.promise;
|
||||
if (id === 's2') return s2.promise;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ songId }: { songId: string }) =>
|
||||
useNowPlayingFetchers({ ...baseDeps, songId }),
|
||||
{ initialProps: { songId: 's1' } },
|
||||
);
|
||||
|
||||
await act(async () => { s1.resolve({ id: 's1', title: 'Track 1' } as SubsonicSong); });
|
||||
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 1'));
|
||||
|
||||
rerender({ songId: 's2' });
|
||||
expect(result.current.songMeta).toBeNull();
|
||||
|
||||
await act(async () => { s2.resolve({ id: 's2', title: 'Track 2' } as SubsonicSong); });
|
||||
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 2'));
|
||||
});
|
||||
|
||||
it('gates albumData on albumId match', async () => {
|
||||
const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||||
const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||||
vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => {
|
||||
if (id === 'alb1') return al1.promise as unknown as AlbumForServer;
|
||||
if (id === 'alb2') return al2.promise as unknown as AlbumForServer;
|
||||
return null as unknown as AlbumForServer;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ albumId }: { albumId: string }) =>
|
||||
useNowPlayingFetchers({ ...baseDeps, albumId }),
|
||||
{ initialProps: { albumId: 'alb1' } },
|
||||
);
|
||||
|
||||
await act(async () => { al1.resolve({ album: { id: 'alb1', name: 'A1' } as SubsonicAlbum, songs: [] }); });
|
||||
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A1'));
|
||||
|
||||
rerender({ albumId: 'alb2' });
|
||||
expect(result.current.albumData).toBeNull();
|
||||
|
||||
await act(async () => { al2.resolve({ album: { id: 'alb2', name: 'A2' } as SubsonicAlbum, songs: [] }); });
|
||||
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A2'));
|
||||
});
|
||||
|
||||
it('gates discography on artistId match (empty fallback while gated)', async () => {
|
||||
const d1 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
|
||||
const d2 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
|
||||
vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => {
|
||||
if (id === 'art-D1') return d1.promise as unknown as ArtistForServer;
|
||||
if (id === 'art-D2') return d2.promise as unknown as ArtistForServer;
|
||||
return { albums: [] } as unknown as ArtistForServer;
|
||||
});
|
||||
mockArtistInfo.mockResolvedValue(null);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ artistId }: { artistId: string }) =>
|
||||
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
||||
{ initialProps: { artistId: 'art-D1' } },
|
||||
);
|
||||
|
||||
await act(async () => { d1.resolve({ artist: {}, albums: [{ id: 'al-D1' } as SubsonicAlbum] }); });
|
||||
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D1']));
|
||||
|
||||
rerender({ artistId: 'art-D2' });
|
||||
expect(result.current.discography).toEqual([]);
|
||||
|
||||
await act(async () => { d2.resolve({ artist: {}, albums: [{ id: 'al-D2' } as SubsonicAlbum] }); });
|
||||
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D2']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useNowPlayingFetchers — local-playback metadata', () => {
|
||||
// Regression: the metadata gate must never pass the playing track id, or the
|
||||
// guard's `psysonic-local://` skip would blank every Subsonic card whenever
|
||||
// the track plays from hot-cache / offline bytes. Guard is called with the
|
||||
// server id only.
|
||||
// Keep the shared guard mock at its permissive default after the behaviour
|
||||
// case below swaps in a trackId-sensitive implementation.
|
||||
afterEach(() => {
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it('queries the network guard without a trackId', async () => {
|
||||
const guard = vi.mocked(shouldAttemptSubsonicForServer);
|
||||
renderHook(() => useNowPlayingFetchers({ ...baseDeps, songId: 'song-1', albumId: 'al-1', artistId: 'art-1', artistName: 'Artist' }));
|
||||
await waitFor(() => expect(guard).toHaveBeenCalled());
|
||||
for (const call of guard.mock.calls) {
|
||||
expect(call).toHaveLength(1);
|
||||
expect(call[1]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('still loads album / discography / top songs when the playback bytes are local', async () => {
|
||||
// Mirror the real guard: a byte-style call (with a trackId resolving to
|
||||
// psysonic-local://) is blocked, but the metadata gate (server id only) is
|
||||
// allowed. If the hook ever passed the trackId again, every fetch below
|
||||
// would be gated off and the cards would blank — exactly the #1042 bug.
|
||||
// Ids are unique to this test so the shared module caches don't short-circuit it.
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
|
||||
(_serverId, trackId) => trackId === undefined,
|
||||
);
|
||||
vi.mocked(getSongForServer).mockResolvedValue({ id: 'np-song', title: 'Local Track' } as SubsonicSong);
|
||||
vi.mocked(getAlbumForServer).mockResolvedValue(
|
||||
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] },
|
||||
);
|
||||
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' } as SubsonicAlbum] } as unknown as ArtistForServer);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' } as unknown as SubsonicSong]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNowPlayingFetchers({ ...baseDeps, songId: 'np-song', albumId: 'np-al', artistId: 'np-art', artistName: 'NP Artist' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getAlbumForServer).toHaveBeenCalledWith('srv1', 'np-al'));
|
||||
await waitFor(() => expect(getArtistForServer).toHaveBeenCalledWith('srv1', 'np-art'));
|
||||
await waitFor(() => expect(getTopSongsForServer).toHaveBeenCalledWith('srv1', 'NP Artist'));
|
||||
await waitFor(() => expect(result.current.albumData?.album.id).toBe('np-al'));
|
||||
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['np-al']));
|
||||
await waitFor(() => expect(result.current.topSongs.map(s => s.id)).toEqual(['np-top']));
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||
import type { ArtistStats, TrackStats } from '../music-network';
|
||||
import { getMusicNetworkRuntimeOrNull } from '../music-network';
|
||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
|
||||
// Module-level TTL caches (shared across mounts)
|
||||
const songMetaCache = makeCache<SubsonicSong | null>();
|
||||
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
|
||||
const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
||||
const topSongsCache = makeCache<SubsonicSong[]>();
|
||||
const tourCache = makeCache<BandsintownEvent[]>();
|
||||
const discographyCache = makeCache<SubsonicAlbum[]>();
|
||||
const networkTrackCache = makeCache<TrackStats | null>();
|
||||
const networkArtistCache = makeCache<ArtistStats | null>();
|
||||
|
||||
export interface NowPlayingFetchersDeps {
|
||||
songId: string | undefined;
|
||||
artistId: string | undefined;
|
||||
albumId: string | undefined;
|
||||
artistName: string;
|
||||
enableBandsintown: boolean;
|
||||
audiomuseNavidromeEnabled: boolean;
|
||||
enrichmentKey: string;
|
||||
currentTrack: { artist: string; title: string } | null;
|
||||
/** Subsonic server for API calls — must match the playing queue server. */
|
||||
subsonicServerId: string;
|
||||
/**
|
||||
* Caller intent / prerequisites only (e.g. "we have a playback server id").
|
||||
* The network reachability decision — online, server reachable, and no
|
||||
* trackId so local-cache playback still loads metadata — is made here via
|
||||
* `shouldAttemptSubsonicForServer`; callers must not pre-apply that guard.
|
||||
*/
|
||||
fetchEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NowPlayingFetchersResult {
|
||||
songMeta: SubsonicSong | null;
|
||||
artistInfo: SubsonicArtistInfo | null;
|
||||
albumData: { album: SubsonicAlbum; songs: SubsonicSong[] } | null;
|
||||
topSongs: SubsonicSong[];
|
||||
tourEvents: BandsintownEvent[];
|
||||
tourLoading: boolean;
|
||||
discography: SubsonicAlbum[];
|
||||
networkTrack: TrackStats | null;
|
||||
networkArtist: ArtistStats | null;
|
||||
}
|
||||
|
||||
function subsonicCacheKey(serverId: string, id: string): string {
|
||||
return serverId ? `${serverId}:${id}` : id;
|
||||
}
|
||||
|
||||
// id-keyed slots are held as `{ id, value }` tuples and gated on id-match in
|
||||
// the return statement. Without the gate, a track switch renders one frame
|
||||
// with the previous track's value paired with the new id — consumers that
|
||||
// build a cacheKey from the new id (e.g. CachedImage) would persist a
|
||||
// mismatched blob in IndexedDB and never recover. See PR #732 for the same
|
||||
// fix inside `NowPlayingInfo.tsx`.
|
||||
type IdSlot<T> = { id: string; value: T } | null;
|
||||
type KeySlot<T> = { key: string; value: T } | null;
|
||||
function seedSlot<T>(id: string, lookup: (id: string) => T | undefined): IdSlot<T> {
|
||||
if (!id) return null;
|
||||
const cached = lookup(id);
|
||||
return cached === undefined ? null : { id, value: cached };
|
||||
}
|
||||
|
||||
function seedKeySlot<T>(key: string, lookup: (key: string) => T | undefined): KeySlot<T> {
|
||||
if (!key) return null;
|
||||
const cached = lookup(key);
|
||||
return cached === undefined ? null : { key, value: cached };
|
||||
}
|
||||
|
||||
export async function prewarmNowPlayingFetchers(
|
||||
deps: NowPlayingFetchersDeps,
|
||||
): Promise<void> {
|
||||
const {
|
||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
|
||||
} = deps;
|
||||
|
||||
if (!fetchEnabled || !subsonicServerId) return;
|
||||
// Index-first resolvers run whenever there's a server id (offline included) —
|
||||
// each guards its own network fallback. artistInfo below is the one
|
||||
// network-only job, so it keeps the reachability gate.
|
||||
|
||||
const jobs: Array<Promise<unknown>> = [];
|
||||
|
||||
if (songId) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
if (songMetaCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpSongMeta(subsonicServerId, songId)
|
||||
.then(v => songMetaCache.set(cacheKey, v ?? null))
|
||||
.catch(() => songMetaCache.set(cacheKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (artistId) {
|
||||
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
// artistInfo (bio/similar) is network-only — keep the reachability gate.
|
||||
if (shouldAttemptSubsonicForServer(subsonicServerId) && artistInfoCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
getArtistInfoForServer(subsonicServerId, artistId, {
|
||||
similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined,
|
||||
})
|
||||
.then(v => artistInfoCache.set(artistKey, v ?? null))
|
||||
.catch(() => artistInfoCache.set(artistKey, null)),
|
||||
);
|
||||
}
|
||||
if (discographyCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpDiscography(subsonicServerId, artistId)
|
||||
.then(albums => discographyCache.set(artistKey, albums))
|
||||
.catch(() => discographyCache.set(artistKey, [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (albumId) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
if (albumCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpAlbum(subsonicServerId, albumId)
|
||||
.then(v => albumCache.set(cacheKey, v))
|
||||
.catch(() => albumCache.set(cacheKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (artistName) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
|
||||
if (topSongsCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
resolveNpTopSongs(subsonicServerId, artistId, artistName)
|
||||
.then(v => topSongsCache.set(cacheKey, v))
|
||||
.catch(() => topSongsCache.set(cacheKey, [])),
|
||||
);
|
||||
}
|
||||
if (enableBandsintown && tourCache.get(artistName) === undefined) {
|
||||
jobs.push(
|
||||
fetchBandsintownEvents(artistName)
|
||||
.then(v => tourCache.set(artistName, v))
|
||||
.catch(() => tourCache.set(artistName, [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prewarmRuntime = getMusicNetworkRuntimeOrNull();
|
||||
if (prewarmRuntime?.getEnrichmentPrimaryId() && currentTrack) {
|
||||
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}`;
|
||||
if (networkTrackCache.get(trackKey) === undefined) {
|
||||
jobs.push(
|
||||
prewarmRuntime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
|
||||
.then(v => networkTrackCache.set(trackKey, v))
|
||||
.catch(() => networkTrackCache.set(trackKey, null)),
|
||||
);
|
||||
}
|
||||
const artistKey = `${currentTrack.artist} ${enrichmentKey}`;
|
||||
if (networkArtistCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
prewarmRuntime.getArtistStats(currentTrack.artist)
|
||||
.then(v => networkArtistCache.set(artistKey, v))
|
||||
.catch(() => networkArtistCache.set(artistKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(jobs);
|
||||
}
|
||||
|
||||
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
|
||||
const {
|
||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
|
||||
} = deps;
|
||||
|
||||
// id-keyed entity state — seeded from TTL cache so same-artist song switches
|
||||
// are instant. Held as `{ id, value }` tuples and gated below.
|
||||
const [songMetaEntry, setSongMetaEntry] = useState<IdSlot<SubsonicSong | null>>(() =>
|
||||
seedSlot(songId && subsonicServerId ? songId : '', k => songMetaCache.get(subsonicCacheKey(subsonicServerId, k))));
|
||||
const [artistInfoEntry, setArtistInfoEntry] = useState<IdSlot<SubsonicArtistInfo | null>>(() =>
|
||||
seedSlot(artistId && subsonicServerId ? artistId : '', k => artistInfoCache.get(subsonicCacheKey(subsonicServerId, k))));
|
||||
const [albumDataEntry, setAlbumDataEntry] = useState<IdSlot<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>>(() =>
|
||||
seedSlot(albumId && subsonicServerId ? albumId : '', k => albumCache.get(subsonicCacheKey(subsonicServerId, k))));
|
||||
const [discographyEntry, setDiscographyEntry] = useState<IdSlot<SubsonicAlbum[]>>(() =>
|
||||
seedSlot(artistId && subsonicServerId ? artistId : '', k => discographyCache.get(subsonicCacheKey(subsonicServerId, k))));
|
||||
|
||||
// Name-keyed / global state — no cacheKey/persistence hazard, kept as plain state.
|
||||
const topSongsKey = artistName && subsonicServerId ? subsonicCacheKey(subsonicServerId, artistName) : '';
|
||||
const tourKey = enableBandsintown && artistName ? artistName : '';
|
||||
const [topSongsEntry, setTopSongsEntry] = useState<KeySlot<SubsonicSong[]>>(() =>
|
||||
seedKeySlot(topSongsKey, k => topSongsCache.get(k)));
|
||||
const [tourEventsEntry, setTourEventsEntry] = useState<KeySlot<BandsintownEvent[]>>(() =>
|
||||
seedKeySlot(tourKey, k => tourCache.get(k)));
|
||||
const [tourLoading, setTourLoading] = useState(false);
|
||||
const networkTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}` : '';
|
||||
const networkArtistKey = artistName ? `${artistName} ${enrichmentKey}` : '';
|
||||
const [networkTrackEntry, setNetworkTrackEntry] = useState<KeySlot<TrackStats | null>>(() =>
|
||||
seedKeySlot(networkTrackKey, k => networkTrackCache.get(k)));
|
||||
const [networkArtistEntry, setNetworkArtistEntry] = useState<KeySlot<ArtistStats | null>>(() =>
|
||||
seedKeySlot(networkArtistKey, k => networkArtistCache.get(k)));
|
||||
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
// Gate split (PR #1049): index-first resolvers run whenever there's a server id
|
||||
// — they read SQLite even when the server is unreachable (the offline win) and
|
||||
// guard their own network fallback. Only artistInfo (bio/similar, no index) is
|
||||
// network-only, so it keeps the reachability gate.
|
||||
const indexFetchAllowed = fetchEnabled && !!subsonicServerId;
|
||||
const networkOnlyAllowed = indexFetchAllowed && shouldAttemptSubsonicForServer(subsonicServerId);
|
||||
|
||||
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!indexFetchAllowed || !songId) { setSongMetaEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
const cached = songMetaCache.get(cacheKey);
|
||||
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
|
||||
setSongMetaEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpSongMeta(subsonicServerId, songId)
|
||||
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
|
||||
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!networkOnlyAllowed || !artistId) { setArtistInfoEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = artistInfoCache.get(cacheKey);
|
||||
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
|
||||
setArtistInfoEntry(null);
|
||||
let cancelled = false;
|
||||
getArtistInfoForServer(subsonicServerId, artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
|
||||
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!indexFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
const cached = albumCache.get(cacheKey);
|
||||
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
|
||||
setAlbumDataEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpAlbum(subsonicServerId, albumId)
|
||||
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!indexFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||
const cached = topSongsCache.get(topSongsKey);
|
||||
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
||||
setTopSongsEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpTopSongs(subsonicServerId, artistId, artistName)
|
||||
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
||||
const cached = tourCache.get(tourKey);
|
||||
if (cached !== undefined) { setTourEventsEntry({ key: tourKey, value: cached }); setTourLoading(false); return; }
|
||||
let cancelled = false;
|
||||
setTourLoading(true);
|
||||
setTourEventsEntry(null);
|
||||
fetchBandsintownEvents(artistName)
|
||||
.then(v => { if (!cancelled) { tourCache.set(tourKey, v); setTourEventsEntry({ key: tourKey, value: v }); } })
|
||||
.finally(() => { if (!cancelled) setTourLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [tourKey, artistName]);
|
||||
|
||||
// Discography via getArtist
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!indexFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
const cached = discographyCache.get(cacheKey);
|
||||
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
|
||||
setDiscographyEntry(null);
|
||||
let cancelled = false;
|
||||
resolveNpDiscography(subsonicServerId, artistId)
|
||||
.then(albums => { if (!cancelled) { discographyCache.set(cacheKey, albums); setDiscographyEntry({ id: artistId, value: albums }); } })
|
||||
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
|
||||
|
||||
// Enrichment track stats (per-track, from the enrichment primary)
|
||||
useEffect(() => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; }
|
||||
const cached = networkTrackCache.get(networkTrackKey);
|
||||
if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; }
|
||||
setNetworkTrackEntry(null);
|
||||
let cancelled = false;
|
||||
runtime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
|
||||
.then(v => { if (!cancelled) { networkTrackCache.set(networkTrackKey, v); setNetworkTrackEntry({ key: networkTrackKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { networkTrackCache.set(networkTrackKey, null); setNetworkTrackEntry({ key: networkTrackKey, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [networkTrackKey, currentTrack, enrichmentKey]);
|
||||
|
||||
// Enrichment artist stats (per-artist — shared across same-artist tracks)
|
||||
useEffect(() => {
|
||||
const runtime = getMusicNetworkRuntimeOrNull();
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; }
|
||||
const cached = networkArtistCache.get(networkArtistKey);
|
||||
if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; }
|
||||
setNetworkArtistEntry(null);
|
||||
let cancelled = false;
|
||||
runtime.getArtistStats(artistName)
|
||||
.then(v => { if (!cancelled) { networkArtistCache.set(networkArtistKey, v); setNetworkArtistEntry({ key: networkArtistKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { networkArtistCache.set(networkArtistKey, null); setNetworkArtistEntry({ key: networkArtistKey, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [networkArtistKey, artistName, enrichmentKey]);
|
||||
|
||||
// Gate id-keyed slots on id-match so consumers never see a value paired
|
||||
// with the wrong id, even on the single render between an id change and
|
||||
// the next effect run.
|
||||
const songMeta = songMetaEntry && songMetaEntry.id === songId ? songMetaEntry.value : null;
|
||||
const artistInfo = artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.value : null;
|
||||
const albumData = albumDataEntry && albumDataEntry.id === albumId ? albumDataEntry.value : null;
|
||||
const discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : [];
|
||||
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
|
||||
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
|
||||
const networkTrack = networkTrackEntry && networkTrackEntry.key === networkTrackKey ? networkTrackEntry.value : null;
|
||||
const networkArtist = networkArtistEntry && networkArtistEntry.key === networkArtistKey ? networkArtistEntry.value : null;
|
||||
|
||||
return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, networkTrack, networkArtist };
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||
import { coverIndexKeyFromRef } from '../cover/storageKeys';
|
||||
import { useNowPlayingPrewarm } from './useNowPlayingPrewarm';
|
||||
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { makeTrack } from '../test/helpers/factories';
|
||||
import { resetAllStores } from '../test/helpers/storeReset';
|
||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||
|
||||
vi.mock('../api/coverCache', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../api/coverCache')>();
|
||||
return {
|
||||
...actual,
|
||||
coverCachePeekBatch: vi.fn(async () => ({})),
|
||||
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./useNowPlayingFetchers', () => ({
|
||||
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function seedServers(): { active: string; playback: string } {
|
||||
const active = useAuthStore.getState().addServer({
|
||||
name: 'Active',
|
||||
url: 'https://active.test',
|
||||
username: 'active-user',
|
||||
password: 'active-pass',
|
||||
});
|
||||
const playback = useAuthStore.getState().addServer({
|
||||
name: 'Playback',
|
||||
url: 'https://playback.test',
|
||||
username: 'play-user',
|
||||
password: 'play-pass',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(active);
|
||||
return { active, playback };
|
||||
}
|
||||
|
||||
describe('useNowPlayingPrewarm', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('prewarms track data and artwork with playback scope', async () => {
|
||||
const { playback } = seedServers();
|
||||
const track = makeTrack({
|
||||
id: 'song-1',
|
||||
artistId: 'artist-1',
|
||||
albumId: 'album-1',
|
||||
artist: 'Artist One',
|
||||
coverArt: 'cover-1',
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
queueItems: toQueueItemRefs(playback, [track]),
|
||||
queueIndex: 0,
|
||||
queueServerId: playback,
|
||||
currentTrack: track,
|
||||
});
|
||||
|
||||
renderHook(() => useNowPlayingPrewarm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(prewarmNowPlayingFetchers).toHaveBeenCalledTimes(1);
|
||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
|
||||
const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback);
|
||||
expect(playbackProfile).toBeDefined();
|
||||
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('playback.test');
|
||||
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||
expect(ensureRef?.serverScope.kind).toBe('server');
|
||||
});
|
||||
|
||||
it('prewarms radio artwork with active scope (not playback queue scope)', async () => {
|
||||
const { active, playback } = seedServers();
|
||||
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
|
||||
usePlayerStore.setState({
|
||||
queueItems: toQueueItemRefs(playback, [track]),
|
||||
queueIndex: 0,
|
||||
queueServerId: playback,
|
||||
currentTrack: null,
|
||||
currentRadio: {
|
||||
id: 'radio-1',
|
||||
name: 'Radio 1',
|
||||
streamUrl: 'https://radio.test/stream',
|
||||
coverArt: 'https://radio.test/art.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() => useNowPlayingPrewarm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
|
||||
const activeProfile = useAuthStore.getState().servers.find(s => s.id === active);
|
||||
expect(activeProfile).toBeDefined();
|
||||
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('active.test');
|
||||
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||
import { albumCoverRef } from '../cover/ref';
|
||||
import { resolvePlaybackCoverScope } from '../cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
|
||||
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import type { CoverArtRef } from '../cover/types';
|
||||
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
async function prewarmCoverRef(ref: CoverArtRef): Promise<void> {
|
||||
if (!ref.fetchCoverArtId) return;
|
||||
const tier = resolveCoverDisplayTier(NOW_PLAYING_COVER_CSS_PX, { surface: 'sparse' });
|
||||
const storageKey = coverStorageKeyFromRef(ref, tier);
|
||||
if (getDiskSrc(storageKey)) return;
|
||||
|
||||
const hits = await coverCachePeekBatch([ref], tier);
|
||||
const hitPath = hits[storageKey];
|
||||
if (hitPath) {
|
||||
rememberDiskSrc(storageKey, hitPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const ensured = await coverCacheEnsure(ref, tier, 'high');
|
||||
if (ensured.hit && ensured.path) {
|
||||
rememberDiskSrc(storageKey, ensured.path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the Now Playing data + key artwork as soon as the playing track changes,
|
||||
* so opening `/now-playing` shows track-correct content instantly.
|
||||
*/
|
||||
export function useNowPlayingPrewarm(): void {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const playbackServerId = usePlaybackServerId();
|
||||
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
|
||||
);
|
||||
const enrichmentKey = useAuthStore(s => s.enrichmentPrimaryId ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack || !playbackServerId) return;
|
||||
|
||||
const primary = primaryTrackArtistRef(currentTrack);
|
||||
void prewarmNowPlayingFetchers({
|
||||
songId: currentTrack.id,
|
||||
artistId: primary.id,
|
||||
albumId: currentTrack.albumId,
|
||||
artistName: primary.name ?? currentTrack.artist,
|
||||
enableBandsintown,
|
||||
audiomuseNavidromeEnabled,
|
||||
enrichmentKey,
|
||||
currentTrack,
|
||||
subsonicServerId: playbackServerId,
|
||||
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
|
||||
// reachability gate, and metadata must warm even when the track's audio
|
||||
// plays from local cache.
|
||||
});
|
||||
|
||||
if (currentTrack.albumId && currentTrack.id) {
|
||||
void resolveTrackCoverRefFromLibrary(
|
||||
{
|
||||
id: currentTrack.id,
|
||||
albumId: currentTrack.albumId,
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
).then(ref => {
|
||||
if (ref) void prewarmCoverRef(ref);
|
||||
});
|
||||
}
|
||||
// Keyed on currentTrack?.id; depending on the `currentTrack` object would
|
||||
// re-prewarm on every render when its identity changes but its id does not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
currentTrack?.id,
|
||||
currentTrack?.artistId,
|
||||
currentTrack?.artists,
|
||||
currentTrack?.albumId,
|
||||
currentTrack?.coverArt,
|
||||
currentTrack?.artist,
|
||||
playbackServerId,
|
||||
enableBandsintown,
|
||||
audiomuseNavidromeEnabled,
|
||||
enrichmentKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRadio?.coverArt || !activeServerId) return;
|
||||
const radioCoverArtId = coverArtIdFromRadio(currentRadio.id);
|
||||
void prewarmCoverRef(albumCoverRef(radioCoverArtId, radioCoverArtId, { kind: 'active' }));
|
||||
}, [currentRadio?.id, currentRadio?.coverArt, activeServerId]);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import type { TrackStats } from '../music-network';
|
||||
import { getMusicNetworkRuntime } from '../music-network';
|
||||
|
||||
export interface NowPlayingStarLoveDeps {
|
||||
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
|
||||
songMeta: SubsonicSong | null;
|
||||
networkTrack: TrackStats | null;
|
||||
networkLoveEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface NowPlayingStarLoveResult {
|
||||
starred: boolean;
|
||||
networkLoved: boolean;
|
||||
toggleStar: () => Promise<void>;
|
||||
toggleNetworkLove: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
|
||||
const { currentTrack, songMeta, networkTrack, networkLoveEnabled } = deps;
|
||||
|
||||
// Star
|
||||
const [starred, setStarred] = useState(false);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const next = !starred;
|
||||
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
|
||||
queueSongStar(currentTrack.id, next, currentTrack.serverId);
|
||||
}, [currentTrack, starred]);
|
||||
|
||||
// Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove)
|
||||
const [networkLoved, setNetworkLoved] = useState(false);
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]);
|
||||
const toggleNetworkLove = useCallback(async () => {
|
||||
if (!currentTrack || !networkLoveEnabled) return;
|
||||
const track = { title: currentTrack.title, artist: currentTrack.artist };
|
||||
if (networkLoved) { await getMusicNetworkRuntime().setTrackLoved(track, false); setNetworkLoved(false); }
|
||||
else { await getMusicNetworkRuntime().setTrackLoved(track, true); setNetworkLoved(true); }
|
||||
}, [currentTrack, networkLoved, networkLoveEnabled]);
|
||||
|
||||
return { starred, networkLoved, toggleStar, toggleNetworkLove };
|
||||
}
|
||||
Reference in New Issue
Block a user