diff --git a/src/api/endpoints/radio.ts b/src/api/endpoints/radio.ts new file mode 100644 index 0000000..b2e73a8 --- /dev/null +++ b/src/api/endpoints/radio.ts @@ -0,0 +1,67 @@ +import { api } from '../index'; +import { toTrack, type RawTrack } from '../mappers'; +import type { RadioResult } from '../types'; + +// Radio + similarity (§6.5). Stateless: the client passes the seed + already- +// queued ids and pulls more as the queue drains. + +interface RawRadioTrack { + track: RawTrack; + reason: string; +} +interface RawRadio { + source: string; + tracks: RawRadioTrack[]; +} +interface RawSimilar { + source: string; + tracks: RawTrack[]; +} + +export interface RadioArgs { + seedTrackId?: string; + fromLikes?: boolean; + exploration?: number; + count?: number; + excludeIds?: string[]; +} + +const toBody = (a: RadioArgs) => ({ + seed_track_id: a.seedTrackId ?? null, + from_likes: a.fromLikes ?? false, + exploration: a.exploration ?? 0.25, + count: a.count ?? 20, + exclude_ids: a.excludeIds ?? [], +}); + +const toRadio = (r: RawRadio): RadioResult => ({ + source: r.source, + picks: r.tracks.map((x) => ({ track: toTrack(x.track), reason: x.reason })), +}); + +export const radioApi = api.injectEndpoints({ + endpoints: (build) => ({ + startRadio: build.mutation({ + query: (a) => ({ url: '/radio', method: 'POST', body: toBody(a) }), + transformResponse: (r: RawRadio) => toRadio(r), + }), + radioNext: build.mutation({ + query: (a) => ({ url: '/radio/next', method: 'POST', body: toBody(a) }), + transformResponse: (r: RawRadio) => toRadio(r), + }), + getSimilarTracks: build.query({ + query: (trackId) => `/tracks/${trackId}/similar`, + transformResponse: (r: RawSimilar) => ({ + source: r.source, + picks: r.tracks.map((t) => ({ track: toTrack(t), reason: 'similar' })), + }), + }), + }), + overrideExisting: false, +}); + +export const { + useStartRadioMutation, + useRadioNextMutation, + useGetSimilarTracksQuery, +} = radioApi; diff --git a/src/api/types.ts b/src/api/types.ts index 73715ea..bafb7e1 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -192,6 +192,20 @@ export interface DuplicateGroup { tracks: Track[]; } +/** One radio/similarity pick with its reason code (§6.5). `reason`: `ml` | + * `similar` | `from_likes` | `discover` — localized client-side. */ +export interface RadioPick { + track: Track; + reason: string; +} + +/** Radio / similar response: where the picks came from + the picks. `source`: + * `ml` (recommender) or `metadata` (fallback heuristics). */ +export interface RadioResult { + source: string; + picks: RadioPick[]; +} + /** Cached lyrics for a track (§6.7). `not_found` is a normal state, not an * error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */ export interface Lyrics { diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index bbfd724..60a3cb4 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar'; import { PersistentPlayer } from '../player/PersistentPlayer'; import { NowPlaying } from '../player/NowPlaying'; import { QueuePanel } from '../player/QueuePanel'; +import { RadioController } from '../player/RadioController'; import { TrackInfoDrawer } from '../track/TrackInfoDrawer'; import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog'; import { LoadingSkeleton } from '../common/LoadingSkeleton'; @@ -39,6 +40,7 @@ export function AppShell() { + ); } diff --git a/src/components/player/QueuePanel.tsx b/src/components/player/QueuePanel.tsx index 13271a7..c2914ef 100644 --- a/src/components/player/QueuePanel.tsx +++ b/src/components/player/QueuePanel.tsx @@ -36,6 +36,7 @@ import { clearQueue, toggleShuffle, toggleLoop, + setExploration, type QueueEntry, } from '../../store/slices/queue'; import { toggleQueue } from '../../store/slices/player'; @@ -148,7 +149,8 @@ export function QueuePanel() { min={0} max={100} step={1} - defaultValue={[42]} + value={[Math.round((queue.radio?.exploration ?? 0.25) * 100)]} + onValueChange={([v]) => dispatch(setExploration(v / 100))} aria-label="Exploration" /> {t('queue.new')} @@ -277,6 +279,14 @@ function QueueRow({ className="r" text={resolved?.artistName ?? entry.artistName} /> + {entry.reason && ( + + {t(`queue.reason.${entry.reason}`)} + + )} diff --git a/src/components/player/RadioController.tsx b/src/components/player/RadioController.tsx new file mode 100644 index 0000000..fe48182 --- /dev/null +++ b/src/components/player/RadioController.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; +import { useRadioNextMutation } from '../../api/endpoints/radio'; +import { appendToQueue } from '../../store/slices/queue'; +import { pickToEntry } from '../../lib/radio'; + +// Prefetch more radio tracks once the current position is within this many of +// the end of the queue. +const LOOKAHEAD = 3; + +/** + * Headless controller: while a radio drives the queue, top it up as it drains + * (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just + * stops the feed — playback of what's already queued is unaffected. + */ +export function RadioController() { + const dispatch = useAppDispatch(); + const radio = useAppSelector((s) => s.queue.radio); + const currentIndex = useAppSelector((s) => s.queue.currentIndex); + const entries = useAppSelector((s) => s.queue.entries); + const [radioNext] = useRadioNextMutation(); + const loadingRef = useRef(false); + + useEffect(() => { + if (!radio) return; + const nearEnd = currentIndex >= entries.length - LOOKAHEAD; + if (!nearEnd || loadingRef.current) return; + + loadingRef.current = true; + void (async () => { + try { + const res = await radioNext({ + seedTrackId: radio.seedTrackId ?? undefined, + fromLikes: radio.fromLikes, + exploration: radio.exploration, + count: 10, + excludeIds: entries.map((e) => e.trackId), + }).unwrap(); + if (res.picks.length > 0) { + dispatch(appendToQueue(res.picks.map(pickToEntry))); + } + } catch { + // best-effort — leave the queue as-is + } finally { + loadingRef.current = false; + } + })(); + }, [radio, currentIndex, entries, radioNext, dispatch]); + + return null; +} diff --git a/src/components/track/TrackContextMenu.tsx b/src/components/track/TrackContextMenu.tsx index cfc9bcb..184a1bc 100644 --- a/src/components/track/TrackContextMenu.tsx +++ b/src/components/track/TrackContextMenu.tsx @@ -11,6 +11,7 @@ import { useAppDispatch } from '../../hooks/useAppDispatch'; import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { play } from '../../store/slices/player'; import { openTrackInfo } from '../../store/slices/ui'; +import { useStartRadio } from '../../hooks/useStartRadio'; import type { Track } from '../../api/types'; interface Props { @@ -32,6 +33,7 @@ export function TrackContextMenu({ }: Props) { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const { start: startRadio } = useStartRadio(); const entry = { trackId: track.id, @@ -75,6 +77,9 @@ export function TrackContextMenu({ > {t('track.menu.addToQueue')} + void startRadio({ seedTrackId: track.id })}> + {t('track.menu.startRadio')} + { diff --git a/src/hooks/useStartRadio.ts b/src/hooks/useStartRadio.ts new file mode 100644 index 0000000..b0878ea --- /dev/null +++ b/src/hooks/useStartRadio.ts @@ -0,0 +1,34 @@ +import { useStartRadioMutation } from '../api/endpoints/radio'; +import { useAppDispatch } from './useAppDispatch'; +import { startRadioQueue } from '../store/slices/queue'; +import { pickToEntry } from '../lib/radio'; + +const DEFAULT_EXPLORATION = 0.25; +const INITIAL_COUNT = 20; + +/** Start a radio (from a seed track or the caller's likes) and hand the picks to + * the play queue; RadioController then keeps it topped up. */ +export function useStartRadio() { + const dispatch = useAppDispatch(); + const [startRadio, { isLoading }] = useStartRadioMutation(); + + const start = async (opts: { seedTrackId?: string; fromLikes?: boolean }) => { + const res = await startRadio({ + ...opts, + exploration: DEFAULT_EXPLORATION, + count: INITIAL_COUNT, + }).unwrap(); + dispatch( + startRadioQueue({ + entries: res.picks.map(pickToEntry), + radio: { + seedTrackId: opts.seedTrackId ?? null, + fromLikes: opts.fromLikes ?? false, + exploration: DEFAULT_EXPLORATION, + }, + }), + ); + }; + + return { start, isStarting: isLoading }; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a476c96..ffd7b8d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -188,6 +188,13 @@ const en = { familiar: 'Familiar', new: 'New', loadingMore: 'Loading more from radio…', + whyPlaying: 'Why this is playing', + reason: { + ml: 'Recommended', + similar: 'Similar', + from_likes: 'From your likes', + discover: 'Something new', + }, doubleClickPlay: 'Double-click to play', removeFromQueue: 'Remove from queue', menu: { @@ -204,6 +211,7 @@ const en = { playNow: 'Play now', playNext: 'Play next', addToQueue: 'Add to queue', + startRadio: 'Start radio', info: 'Track info', addToPlaylist: 'Add to playlist…', removeFromPlaylist: 'Remove from playlist', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 97932f1..3daf8b9 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -189,6 +189,13 @@ const ru: Translations = { familiar: 'Знакомое', new: 'Новое', loadingMore: 'Загрузка радио…', + whyPlaying: 'Почему это играет', + reason: { + ml: 'Рекомендация', + similar: 'Похожее', + from_likes: 'Из ваших лайков', + discover: 'Что-то новое', + }, doubleClickPlay: 'Двойной клик для воспроизведения', removeFromQueue: 'Убрать из очереди', menu: { @@ -205,6 +212,7 @@ const ru: Translations = { playNow: 'Играть сейчас', playNext: 'Следующим', addToQueue: 'Добавить в очередь', + startRadio: 'Запустить радио', info: 'Информация о треке', addToPlaylist: 'Добавить в плейлист…', removeFromPlaylist: 'Убрать из плейлиста', diff --git a/src/index.tsx b/src/index.tsx index 5f31054..adead47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,6 +24,7 @@ import './api/endpoints/admin'; import './api/endpoints/settings'; import './api/endpoints/upload'; import './api/endpoints/lyrics'; +import './api/endpoints/radio'; // Tier 3 offline: register the audio-caching service worker (no-op if the // browser/origin doesn't support it). diff --git a/src/lib/radio.ts b/src/lib/radio.ts new file mode 100644 index 0000000..5f4be34 --- /dev/null +++ b/src/lib/radio.ts @@ -0,0 +1,15 @@ +import type { RadioPick } from '../api/types'; +import type { QueueEntry } from '../store/slices/queue'; + +/** A radio/similar pick → a play-queue entry (carrying its reason code). */ +export function pickToEntry(pick: RadioPick): QueueEntry { + return { + trackId: pick.track.id, + title: pick.track.title, + artistName: pick.track.artistName, + albumTitle: pick.track.albumTitle, + durationMs: pick.track.durationMs, + albumArtUrl: pick.track.albumArtUrl, + reason: pick.reason, + }; +} diff --git a/src/store/slices/queue.ts b/src/store/slices/queue.ts index 4c3ce4f..1f20134 100644 --- a/src/store/slices/queue.ts +++ b/src/store/slices/queue.ts @@ -15,6 +15,15 @@ export interface QueueEntry { albumTitle: string; durationMs: number; albumArtUrl?: string; + /** Radio reason code (`ml`/`similar`/`from_likes`/`discover`) — §6.5. */ + reason?: string; +} + +/** Active-radio context: what seeds the infinite feed + the exploration bias. */ +export interface RadioContext { + seedTrackId: string | null; + fromLikes: boolean; + exploration: number; } export interface QueueState { @@ -25,6 +34,8 @@ export interface QueueState { sourceName: string | null; shuffle: boolean; loop: boolean; + /** Non-null while a radio drives the queue (enables infinite loading). */ + radio: RadioContext | null; } export const queueInitialState: QueueState = { @@ -35,6 +46,7 @@ export const queueInitialState: QueueState = { sourceName: null, shuffle: false, loop: false, + radio: null, }; export const queueSlice = createSlice({ @@ -56,6 +68,27 @@ export const queueSlice = createSlice({ state.source = action.payload.source; state.sourceId = action.payload.sourceId ?? null; state.sourceName = action.payload.sourceName ?? null; + state.radio = null; // a manual/album/… queue ends any active radio + }, + startRadioQueue( + state, + action: PayloadAction<{ entries: QueueEntry[]; radio: RadioContext }>, + ) { + state.entries = action.payload.entries; + state.currentIndex = 0; + state.source = 'radio'; + state.sourceId = null; + state.sourceName = null; + state.radio = action.payload.radio; + }, + appendToQueue(state, action: PayloadAction) { + state.entries.push(...action.payload); + }, + setExploration(state, action: PayloadAction) { + if (state.radio) state.radio.exploration = action.payload; + }, + stopRadio(state) { + state.radio = null; }, addToQueue(state, action: PayloadAction) { state.entries.push(action.payload); @@ -102,6 +135,7 @@ export const queueSlice = createSlice({ clearQueue(state) { state.entries = []; state.currentIndex = -1; + state.radio = null; }, toggleShuffle(state) { state.shuffle = !state.shuffle; @@ -114,6 +148,10 @@ export const queueSlice = createSlice({ export const { setQueue, + startRadioQueue, + appendToQueue, + setExploration, + stopRadio, addToQueue, addNextInQueue, playNow,