feat(player): stream at the chosen quality + per-track optimize (§6.6)
The Playback quality setting now drives streaming: getStreamUrl appends ?quality= and useAudioPlayer feeds the server-synced streamQuality, so the backend transcodes on demand (miss → original). Adds an optimizeTrack mutation and an Optimize action in the metadata editor to warm a track's rendition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,18 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
||||||
}),
|
}),
|
||||||
|
// Enqueue a transcode to a cached Opus + HLS rendition (§6.6). Fire-and-
|
||||||
|
// forget: the worker does the work, playback keeps using the original.
|
||||||
|
optimizeTrack: build.mutation<
|
||||||
|
{ status: string; job_id: string; quality: string },
|
||||||
|
{ trackId: string; quality?: string }
|
||||||
|
>({
|
||||||
|
query: ({ trackId, quality = 'high' }) => ({
|
||||||
|
url: `/tracks/${trackId}/optimize`,
|
||||||
|
method: 'POST',
|
||||||
|
params: { quality },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -219,4 +231,5 @@ export const {
|
|||||||
useLazyGetMetadataMatchesQuery,
|
useLazyGetMetadataMatchesQuery,
|
||||||
useApplyMetadataMutation,
|
useApplyMetadataMutation,
|
||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
|
useOptimizeTrackMutation,
|
||||||
} = libraryApi;
|
} = libraryApi;
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import { getApiBaseUrl } from '../../config/runtime-config';
|
|||||||
* param because `<audio>` can't send an `Authorization` header; the backend
|
* param because `<audio>` can't send an `Authorization` header; the backend
|
||||||
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
||||||
*/
|
*/
|
||||||
export function getStreamUrl(trackId: string, token: string): string {
|
export function getStreamUrl(
|
||||||
|
trackId: string,
|
||||||
|
token: string,
|
||||||
|
quality?: string,
|
||||||
|
): string {
|
||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
|
const q =
|
||||||
|
quality && quality !== 'original'
|
||||||
|
? `&quality=${encodeURIComponent(quality)}`
|
||||||
|
: '';
|
||||||
|
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}${q}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useEnrichTrackMutation,
|
useEnrichTrackMutation,
|
||||||
useGetTrackQuery,
|
useGetTrackQuery,
|
||||||
useLazyGetMetadataMatchesQuery,
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
useOptimizeTrackMutation,
|
||||||
} from '../../api/endpoints/library';
|
} from '../../api/endpoints/library';
|
||||||
import {
|
import {
|
||||||
useGetBrokenFilesQuery,
|
useGetBrokenFilesQuery,
|
||||||
@@ -418,12 +419,67 @@ function SingleTrackEditor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<OptimizeCard trackId={trackId} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enqueue a transcode to a cached Opus + HLS rendition (§6.6 / A6). */
|
||||||
|
function OptimizeCard({ trackId }: { trackId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [optimize, { isLoading, isSuccess, isError }] =
|
||||||
|
useOptimizeTrackMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataEditor.optimize.title')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('metadataEditor.optimize.hint')}
|
||||||
|
</div>
|
||||||
|
{isSuccess && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('metadataEditor.optimize.enqueued')}
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<Callout variant="danger">{t('common.error')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => void optimize({ trackId })}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.optimize.button')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MatchRow({
|
function MatchRow({
|
||||||
match,
|
match,
|
||||||
onUse,
|
onUse,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { nextTrack, prevTrack } from '../store/slices/queue';
|
import { nextTrack, prevTrack } from '../store/slices/queue';
|
||||||
import { play } from '../store/slices/player';
|
import { play } from '../store/slices/player';
|
||||||
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
||||||
|
import { useGetSettingsQuery } from '../api/endpoints/settings';
|
||||||
|
|
||||||
let audioElement: HTMLAudioElement | null = null;
|
let audioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ export function useAudioPlayer() {
|
|||||||
const player = useAppSelector((s) => s.player);
|
const player = useAppSelector((s) => s.player);
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
// Default stream quality (server-synced §A10) → the backend transcodes to it
|
||||||
|
// on demand; a cache miss transparently falls back to the original.
|
||||||
|
const { data: settings } = useGetSettingsQuery();
|
||||||
|
const streamQuality = settings?.streamQuality;
|
||||||
const isSetup = useRef(false);
|
const isSetup = useRef(false);
|
||||||
// `ended` is registered once below; read the latest loop flag through a ref
|
// `ended` is registered once below; read the latest loop flag through a ref
|
||||||
// so the listener doesn't need to be re-bound on every queue change.
|
// so the listener doesn't need to be re-bound on every queue change.
|
||||||
@@ -63,7 +68,7 @@ export function useAudioPlayer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!player.currentTrackId || !accessToken) return;
|
if (!player.currentTrackId || !accessToken) return;
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
const url = getStreamUrl(player.currentTrackId, accessToken);
|
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||||
if (audio.src !== url) {
|
if (audio.src !== url) {
|
||||||
audio.src = url;
|
audio.src = url;
|
||||||
audio.load();
|
audio.load();
|
||||||
@@ -73,7 +78,7 @@ export function useAudioPlayer() {
|
|||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
}
|
}
|
||||||
}, [player.currentTrackId, player.isPlaying, accessToken]);
|
}, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = getAudio();
|
const audio = getAudio();
|
||||||
|
|||||||
@@ -628,6 +628,12 @@ const en = {
|
|||||||
error: 'Failed to load track',
|
error: 'Failed to load track',
|
||||||
saved: 'Metadata saved.',
|
saved: 'Metadata saved.',
|
||||||
saveError: 'Failed to save metadata.',
|
saveError: 'Failed to save metadata.',
|
||||||
|
optimize: {
|
||||||
|
title: 'Optimize format',
|
||||||
|
hint: 'Transcode to a compact Opus + HLS rendition in the background.',
|
||||||
|
button: 'Optimize',
|
||||||
|
enqueued: 'Optimization queued — it runs in the background.',
|
||||||
|
},
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
|||||||
@@ -627,6 +627,12 @@ const ru: Translations = {
|
|||||||
empty: 'В этой категории пусто.',
|
empty: 'В этой категории пусто.',
|
||||||
},
|
},
|
||||||
metadataEditor: {
|
metadataEditor: {
|
||||||
|
optimize: {
|
||||||
|
title: 'Оптимизировать формат',
|
||||||
|
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
|
||||||
|
button: 'Оптимизировать',
|
||||||
|
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
|
||||||
|
},
|
||||||
error: 'Не удалось загрузить трек',
|
error: 'Не удалось загрузить трек',
|
||||||
saved: 'Метаданные сохранены.',
|
saved: 'Метаданные сохранены.',
|
||||||
saveError: 'Не удалось сохранить метаданные.',
|
saveError: 'Не удалось сохранить метаданные.',
|
||||||
|
|||||||
Reference in New Issue
Block a user