Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f7fd25ff | |||
| 5b31e77b1e | |||
| 7f116f0657 | |||
| ac6b76bf98 |
@@ -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<RadioResult, RadioArgs>({
|
||||||
|
query: (a) => ({ url: '/radio', method: 'POST', body: toBody(a) }),
|
||||||
|
transformResponse: (r: RawRadio) => toRadio(r),
|
||||||
|
}),
|
||||||
|
radioNext: build.mutation<RadioResult, RadioArgs>({
|
||||||
|
query: (a) => ({ url: '/radio/next', method: 'POST', body: toBody(a) }),
|
||||||
|
transformResponse: (r: RawRadio) => toRadio(r),
|
||||||
|
}),
|
||||||
|
getSimilarTracks: build.query<RadioResult, string>({
|
||||||
|
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;
|
||||||
@@ -192,6 +192,20 @@ export interface DuplicateGroup {
|
|||||||
tracks: Track[];
|
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
|
/** 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. */
|
* error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */
|
||||||
export interface Lyrics {
|
export interface Lyrics {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
import { NowPlaying } from '../player/NowPlaying';
|
import { NowPlaying } from '../player/NowPlaying';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
|
import { RadioController } from '../player/RadioController';
|
||||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
@@ -39,6 +40,7 @@ export function AppShell() {
|
|||||||
<AddToPlaylistDialog />
|
<AddToPlaylistDialog />
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
<NowPlaying />
|
<NowPlaying />
|
||||||
|
<RadioController />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
clearQueue,
|
clearQueue,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
toggleLoop,
|
toggleLoop,
|
||||||
|
setExploration,
|
||||||
type QueueEntry,
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
@@ -148,7 +149,8 @@ export function QueuePanel() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
defaultValue={[42]}
|
value={[Math.round((queue.radio?.exploration ?? 0.25) * 100)]}
|
||||||
|
onValueChange={([v]) => dispatch(setExploration(v / 100))}
|
||||||
aria-label="Exploration"
|
aria-label="Exploration"
|
||||||
/>
|
/>
|
||||||
<span className="lab">{t('queue.new')}</span>
|
<span className="lab">{t('queue.new')}</span>
|
||||||
@@ -277,6 +279,14 @@ function QueueRow({
|
|||||||
className="r"
|
className="r"
|
||||||
text={resolved?.artistName ?? entry.artistName}
|
text={resolved?.artistName ?? entry.artistName}
|
||||||
/>
|
/>
|
||||||
|
{entry.reason && (
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 11, color: 'var(--color-accent)' }}
|
||||||
|
title={t('queue.whyPlaying')}
|
||||||
|
>
|
||||||
|
{t(`queue.reason.${entry.reason}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Only the most recent tracks are sent as "don't repeat these" — bounding the
|
||||||
|
// request so a long radio session doesn't ship an ever-growing exclude list
|
||||||
|
// (and a huge NOT IN on the server). Recent-repeat avoidance is what matters.
|
||||||
|
const EXCLUDE_WINDOW = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.slice(-EXCLUDE_WINDOW).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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useAppDispatch } from '../../hooks/useAppDispatch';
|
|||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
import { openTrackInfo } from '../../store/slices/ui';
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
|
import { useStartRadio } from '../../hooks/useStartRadio';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +33,7 @@ export function TrackContextMenu({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { start: startRadio } = useStartRadio();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
@@ -75,6 +77,9 @@ export function TrackContextMenu({
|
|||||||
>
|
>
|
||||||
{t('track.menu.addToQueue')}
|
{t('track.menu.addToQueue')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={() => void startRadio({ seedTrackId: track.id })}>
|
||||||
|
{t('track.menu.startRadio')}
|
||||||
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ function fieldStyle(): React.CSSProperties {
|
|||||||
return { width: '100%' };
|
return { width: '100%' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap how many bulk metadata ops run at once — a large selection would otherwise
|
||||||
|
// fire hundreds of simultaneous requests (each also spins up a worker enqueue).
|
||||||
|
const BULK_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
async function settleInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
op: (item: T) => Promise<unknown>,
|
||||||
|
): Promise<PromiseSettledResult<unknown>[]> {
|
||||||
|
const results: PromiseSettledResult<unknown>[] = [];
|
||||||
|
for (let i = 0; i < items.length; i += limit) {
|
||||||
|
const batch = items.slice(i, i + limit);
|
||||||
|
results.push(...(await Promise.allSettled(batch.map(op))));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
||||||
@@ -723,7 +740,7 @@ function BatchEditor() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
const ids = [...selected];
|
const ids = [...selected];
|
||||||
const outcomes = await Promise.allSettled(ids.map(op));
|
const outcomes = await settleInBatches(ids, BULK_CONCURRENCY, op);
|
||||||
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||||
setResult({ ok, fail: ids.length - ok });
|
setResult({ ok, fail: ids.length - ok });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
+38
-21
@@ -31,38 +31,43 @@ export function useAudioPlayer() {
|
|||||||
// on demand; a cache miss transparently falls back to the original.
|
// on demand; a cache miss transparently falls back to the original.
|
||||||
const { data: settings } = useGetSettingsQuery();
|
const { data: settings } = useGetSettingsQuery();
|
||||||
const streamQuality = settings?.streamQuality;
|
const streamQuality = settings?.streamQuality;
|
||||||
const isSetup = useRef(false);
|
// `ended` reads the latest loop flag through a ref so the listener doesn't
|
||||||
// `ended` is registered once below; read the latest loop flag through a ref
|
// need re-binding on every queue change.
|
||||||
// so the listener doesn't need to be re-bound on every queue change.
|
|
||||||
const loopRef = useRef(queue.loop);
|
const loopRef = useRef(queue.loop);
|
||||||
loopRef.current = queue.loop;
|
loopRef.current = queue.loop;
|
||||||
|
// The track id the current <audio>.src points at — lets us tell a real track
|
||||||
|
// change (start at 0) from a same-track URL change (preserve position).
|
||||||
|
const loadedTrackRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSetup.current) return;
|
|
||||||
isSetup.current = true;
|
|
||||||
|
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
|
// Named handlers + cleanup so a remount (React StrictMode, or the hook
|
||||||
audio.addEventListener('timeupdate', () => {
|
// mounting elsewhere) can't leave duplicate listeners double-dispatching.
|
||||||
dispatch(setPosition(audio.currentTime));
|
const onTime = () => dispatch(setPosition(audio.currentTime));
|
||||||
});
|
const onDuration = () => dispatch(setDuration(audio.duration || 0));
|
||||||
audio.addEventListener('durationchange', () => {
|
const onEnded = () => {
|
||||||
dispatch(setDuration(audio.duration || 0));
|
|
||||||
});
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
if (loopRef.current) {
|
if (loopRef.current) {
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
void audio.play();
|
void audio.play();
|
||||||
} else {
|
} else {
|
||||||
dispatch(nextTrack());
|
dispatch(nextTrack());
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
audio.addEventListener('pause', () => {
|
const onPause = () => dispatch(pause());
|
||||||
dispatch(pause());
|
const onPlay = () => dispatch(resume());
|
||||||
});
|
|
||||||
audio.addEventListener('play', () => {
|
audio.addEventListener('timeupdate', onTime);
|
||||||
dispatch(resume());
|
audio.addEventListener('durationchange', onDuration);
|
||||||
});
|
audio.addEventListener('ended', onEnded);
|
||||||
|
audio.addEventListener('pause', onPause);
|
||||||
|
audio.addEventListener('play', onPlay);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', onTime);
|
||||||
|
audio.removeEventListener('durationchange', onDuration);
|
||||||
|
audio.removeEventListener('ended', onEnded);
|
||||||
|
audio.removeEventListener('pause', onPause);
|
||||||
|
audio.removeEventListener('play', onPlay);
|
||||||
|
};
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,8 +75,20 @@ export function useAudioPlayer() {
|
|||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||||
if (audio.src !== url) {
|
if (audio.src !== url) {
|
||||||
|
// Same track, new URL (quality changed, or the access token was refreshed
|
||||||
|
// mid-play) → keep the playback position instead of restarting from 0.
|
||||||
|
const sameTrack = loadedTrackRef.current === player.currentTrackId;
|
||||||
|
const resumeAt = sameTrack ? audio.currentTime : 0;
|
||||||
audio.src = url;
|
audio.src = url;
|
||||||
audio.load();
|
audio.load();
|
||||||
|
loadedTrackRef.current = player.currentTrackId;
|
||||||
|
if (resumeAt > 0) {
|
||||||
|
const onMeta = () => {
|
||||||
|
audio.currentTime = resumeAt;
|
||||||
|
audio.removeEventListener('loadedmetadata', onMeta);
|
||||||
|
};
|
||||||
|
audio.addEventListener('loadedmetadata', onMeta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
void audio.play();
|
void audio.play();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -188,6 +188,13 @@ const en = {
|
|||||||
familiar: 'Familiar',
|
familiar: 'Familiar',
|
||||||
new: 'New',
|
new: 'New',
|
||||||
loadingMore: 'Loading more from radio…',
|
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',
|
doubleClickPlay: 'Double-click to play',
|
||||||
removeFromQueue: 'Remove from queue',
|
removeFromQueue: 'Remove from queue',
|
||||||
menu: {
|
menu: {
|
||||||
@@ -204,6 +211,7 @@ const en = {
|
|||||||
playNow: 'Play now',
|
playNow: 'Play now',
|
||||||
playNext: 'Play next',
|
playNext: 'Play next',
|
||||||
addToQueue: 'Add to queue',
|
addToQueue: 'Add to queue',
|
||||||
|
startRadio: 'Start radio',
|
||||||
info: 'Track info',
|
info: 'Track info',
|
||||||
addToPlaylist: 'Add to playlist…',
|
addToPlaylist: 'Add to playlist…',
|
||||||
removeFromPlaylist: 'Remove from playlist',
|
removeFromPlaylist: 'Remove from playlist',
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ const ru: Translations = {
|
|||||||
familiar: 'Знакомое',
|
familiar: 'Знакомое',
|
||||||
new: 'Новое',
|
new: 'Новое',
|
||||||
loadingMore: 'Загрузка радио…',
|
loadingMore: 'Загрузка радио…',
|
||||||
|
whyPlaying: 'Почему это играет',
|
||||||
|
reason: {
|
||||||
|
ml: 'Рекомендация',
|
||||||
|
similar: 'Похожее',
|
||||||
|
from_likes: 'Из ваших лайков',
|
||||||
|
discover: 'Что-то новое',
|
||||||
|
},
|
||||||
doubleClickPlay: 'Двойной клик для воспроизведения',
|
doubleClickPlay: 'Двойной клик для воспроизведения',
|
||||||
removeFromQueue: 'Убрать из очереди',
|
removeFromQueue: 'Убрать из очереди',
|
||||||
menu: {
|
menu: {
|
||||||
@@ -205,6 +212,7 @@ const ru: Translations = {
|
|||||||
playNow: 'Играть сейчас',
|
playNow: 'Играть сейчас',
|
||||||
playNext: 'Следующим',
|
playNext: 'Следующим',
|
||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
|
startRadio: 'Запустить радио',
|
||||||
info: 'Информация о треке',
|
info: 'Информация о треке',
|
||||||
addToPlaylist: 'Добавить в плейлист…',
|
addToPlaylist: 'Добавить в плейлист…',
|
||||||
removeFromPlaylist: 'Убрать из плейлиста',
|
removeFromPlaylist: 'Убрать из плейлиста',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import './api/endpoints/admin';
|
|||||||
import './api/endpoints/settings';
|
import './api/endpoints/settings';
|
||||||
import './api/endpoints/upload';
|
import './api/endpoints/upload';
|
||||||
import './api/endpoints/lyrics';
|
import './api/endpoints/lyrics';
|
||||||
|
import './api/endpoints/radio';
|
||||||
|
|
||||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||||
// browser/origin doesn't support it).
|
// browser/origin doesn't support it).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@ export interface QueueEntry {
|
|||||||
albumTitle: string;
|
albumTitle: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
albumArtUrl?: string;
|
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 {
|
export interface QueueState {
|
||||||
@@ -25,8 +34,16 @@ export interface QueueState {
|
|||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
|
/** Non-null while a radio drives the queue (enables infinite loading). */
|
||||||
|
radio: RadioContext | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An infinite radio keeps appending, so cap the backlog: once the queue grows
|
||||||
|
// past QUEUE_CAP, drop the oldest *already-played* entries (never the current
|
||||||
|
// track or the look-ahead), keeping at least KEEP_BEHIND for prev-navigation.
|
||||||
|
const QUEUE_CAP = 300;
|
||||||
|
const KEEP_BEHIND = 50;
|
||||||
|
|
||||||
export const queueInitialState: QueueState = {
|
export const queueInitialState: QueueState = {
|
||||||
entries: [],
|
entries: [],
|
||||||
currentIndex: -1,
|
currentIndex: -1,
|
||||||
@@ -35,6 +52,7 @@ export const queueInitialState: QueueState = {
|
|||||||
sourceName: null,
|
sourceName: null,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
loop: false,
|
loop: false,
|
||||||
|
radio: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
@@ -56,6 +74,38 @@ export const queueSlice = createSlice({
|
|||||||
state.source = action.payload.source;
|
state.source = action.payload.source;
|
||||||
state.sourceId = action.payload.sourceId ?? null;
|
state.sourceId = action.payload.sourceId ?? null;
|
||||||
state.sourceName = action.payload.sourceName ?? 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<QueueEntry[]>) {
|
||||||
|
state.entries.push(...action.payload);
|
||||||
|
if (state.entries.length > QUEUE_CAP) {
|
||||||
|
// Only trim entries safely behind the current one.
|
||||||
|
const drop = Math.min(
|
||||||
|
state.entries.length - QUEUE_CAP,
|
||||||
|
Math.max(0, state.currentIndex - KEEP_BEHIND),
|
||||||
|
);
|
||||||
|
if (drop > 0) {
|
||||||
|
state.entries.splice(0, drop);
|
||||||
|
state.currentIndex -= drop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setExploration(state, action: PayloadAction<number>) {
|
||||||
|
if (state.radio) state.radio.exploration = action.payload;
|
||||||
|
},
|
||||||
|
stopRadio(state) {
|
||||||
|
state.radio = null;
|
||||||
},
|
},
|
||||||
addToQueue(state, action: PayloadAction<QueueEntry>) {
|
addToQueue(state, action: PayloadAction<QueueEntry>) {
|
||||||
state.entries.push(action.payload);
|
state.entries.push(action.payload);
|
||||||
@@ -102,6 +152,7 @@ export const queueSlice = createSlice({
|
|||||||
clearQueue(state) {
|
clearQueue(state) {
|
||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.currentIndex = -1;
|
state.currentIndex = -1;
|
||||||
|
state.radio = null;
|
||||||
},
|
},
|
||||||
toggleShuffle(state) {
|
toggleShuffle(state) {
|
||||||
state.shuffle = !state.shuffle;
|
state.shuffle = !state.shuffle;
|
||||||
@@ -114,6 +165,10 @@ export const queueSlice = createSlice({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
setQueue,
|
setQueue,
|
||||||
|
startRadioQueue,
|
||||||
|
appendToQueue,
|
||||||
|
setExploration,
|
||||||
|
stopRadio,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addNextInQueue,
|
addNextInQueue,
|
||||||
playNow,
|
playNow,
|
||||||
|
|||||||
Reference in New Issue
Block a user