feat(playback): choose alternative failed sources

This commit is contained in:
cucadmuh
2026-07-20 01:43:04 +03:00
parent 15bbfe22ac
commit 2b687cb391
33 changed files with 1300 additions and 13 deletions
+2
View File
@@ -17,6 +17,7 @@ import AppRoutes from './AppRoutes';
import FullscreenPlayer, { FullscreenPlayerImmersive, FullscreenPlayerPrism } from '@/features/fullscreenPlayer';
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
import SongInfoModal from '@/features/playback/components/SongInfoModal';
import PlaybackAlternativeModal from '@/features/playback/components/PlaybackAlternativeModal';
import { DownloadFolderModal, OfflineBanner } from '@/features/offline/ui';
import GlobalConfirmModal from '@/ui/GlobalConfirmModal';
import ThemeMigrationNotice from '@/ui/ThemeMigrationNotice';
@@ -345,6 +346,7 @@ export function AppShell() {
)}
<ContextMenu />
<SongInfoModal />
<PlaybackAlternativeModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
const selectPlaybackAlternative = vi.hoisted(() => vi.fn());
vi.mock('@/features/playback/store/selectPlaybackAlternative', () => ({ selectPlaybackAlternative }));
vi.mock('@/features/playback/utils/playback/availablePlaybackAlternativeSources', async importOriginal => ({
...(await importOriginal<typeof import('@/features/playback/utils/playback/availablePlaybackAlternativeSources')>()),
availablePlaybackAlternativeSources: (sources: Array<Record<string, unknown>>) => sources.map(source => ({
...source,
local: true,
serverLabel: 'Bedroom server',
})),
}));
import PlaybackAlternativeModal from './PlaybackAlternativeModal';
import {
_resetPlaybackAlternativeStoreForTest,
usePlaybackAlternativeStore,
} from '@/features/playback/store/playbackAlternativeStore';
beforeEach(() => {
_resetPlaybackAlternativeStoreForTest();
selectPlaybackAlternative.mockReset();
selectPlaybackAlternative.mockResolvedValue(true);
});
function openModal(): void {
usePlaybackAlternativeStore.setState({
failure: {
key: '1:0:a.test:failed',
generation: 1,
queueIndex: 0,
expectedRef: { serverId: 'a.test', trackId: 'failed' },
track: {
id: 'failed',
title: 'Broken Song',
artist: 'Artist',
album: 'Album',
albumId: 'album',
duration: 180,
},
detail: 'decode error',
},
status: 'ready',
sources: [{
serverId: 'srv-b',
id: 'replacement',
libraryId: '',
priority: 0,
durationSec: 180,
suffix: 'flac',
bitRate: 1_000,
sizeBytes: 30_000_000,
starredAt: null,
userRating: null,
}],
selectingKey: null,
actionError: null,
});
}
describe('PlaybackAlternativeModal', () => {
it('announces the failed track and exposes a semantic source action', async () => {
openModal();
renderWithProviders(<PlaybackAlternativeModal />);
const dialog = screen.getByRole('dialog', { name: /couldnt play broken song/i });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(screen.getByText('Playback error: decode error')).toBeInTheDocument();
const sourceButton = screen.getByRole('button', { name: /play from bedroom server/i });
expect(sourceButton).toHaveTextContent('Downloaded · FLAC · 1000 kbps');
await userEvent.click(sourceButton);
expect(selectPlaybackAlternative).toHaveBeenCalledWith(expect.objectContaining({
serverId: 'srv-b',
id: 'replacement',
}));
});
it('moves focus into the dialog, traps Tab, and closes on Escape', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
openModal();
renderWithProviders(<PlaybackAlternativeModal />);
const dialog = screen.getByRole('dialog');
await waitFor(() => expect(dialog).toHaveFocus());
const keepButton = screen.getByRole('button', { name: 'Keep current source' });
keepButton.focus();
await userEvent.keyboard('{Tab}');
expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(opener).toHaveFocus();
opener.remove();
});
});
@@ -0,0 +1,193 @@
import { useEffect, useId, useRef } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, LoaderCircle, Server, TriangleAlert, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import {
usePlaybackAlternativeStore,
} from '@/features/playback/store/playbackAlternativeStore';
import { selectPlaybackAlternative } from '@/features/playback/store/selectPlaybackAlternative';
import {
availablePlaybackAlternativeSources,
type PlaybackAlternativeSource,
} from '@/features/playback/utils/playback/availablePlaybackAlternativeSources';
function sourceMeta(source: PlaybackAlternativeSource, localLabel: string, onlineLabel: string): string {
const parts = [source.local ? localLabel : onlineLabel];
if (source.suffix) parts.push(source.suffix.toUpperCase());
if (source.bitRate) parts.push(`${source.bitRate} kbps`);
return parts.join(' · ');
}
export default function PlaybackAlternativeModal() {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const descriptionId = useId();
const { failure, status, sources, selectingKey, actionError, close } =
usePlaybackAlternativeStore(useShallow(state => ({
failure: state.failure,
status: state.status,
sources: state.sources,
selectingKey: state.selectingKey,
actionError: state.actionError,
close: state.close,
})));
const failureKey = failure?.key ?? null;
const availableSources = availablePlaybackAlternativeSources(sources);
useEffect(() => {
if (!failureKey) return;
const previouslyFocused = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !usePlaybackAlternativeStore.getState().selectingKey) close();
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
)];
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus();
};
}, [close, failureKey]);
if (!failure) return null;
const busy = selectingKey !== null;
return createPortal(
<div
className="modal-overlay playback-alternative-overlay"
role="presentation"
onMouseDown={event => {
if (event.target === event.currentTarget && !busy) close();
}}
>
<div
ref={dialogRef}
className="modal-content playback-alternative-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={busy || status === 'loading'}
tabIndex={-1}
>
<button
type="button"
className="modal-close"
onClick={close}
disabled={busy}
aria-label={t('common.close')}
>
<X size={18} />
</button>
<header className="playback-alternative-modal__header">
<span className="playback-alternative-modal__warning" aria-hidden="true">
<TriangleAlert size={22} />
</span>
<div>
<h2 id={titleId} className="playback-alternative-modal__title">
{t('player.sourceFailureTitle', { title: failure.track.title })}
</h2>
<p id={descriptionId} className="playback-alternative-modal__description">
{t('player.sourceFailureBody')}
</p>
</div>
</header>
<p className="playback-alternative-modal__detail">
{t('player.sourceFailureDetail', { detail: failure.detail })}
</p>
<div className="playback-alternative-modal__body" aria-live="polite">
{status === 'loading' && (
<div className="playback-alternative-modal__status">
<LoaderCircle className="playback-alternative-modal__spinner" size={20} aria-hidden="true" />
<span>{t('player.sourceFailureSearching')}</span>
</div>
)}
{status === 'error' && (
<div className="playback-alternative-modal__status playback-alternative-modal__status--error">
{t('player.sourceFailureLoadError')}
</div>
)}
{status === 'ready' && availableSources.length === 0 && (
<div className="playback-alternative-modal__status">
{t('player.sourceFailureNone')}
</div>
)}
{availableSources.length > 0 && (
<div className="playback-alternative-modal__sources">
{availableSources.map(source => {
const key = `${source.serverId}:${source.id}`;
const selecting = selectingKey === key;
return (
<button
key={key}
type="button"
className="playback-alternative-source"
disabled={busy}
onClick={() => void selectPlaybackAlternative(source)}
>
<span className="playback-alternative-source__icon" aria-hidden="true">
{source.local ? <HardDriveDownload size={20} /> : <Server size={20} />}
</span>
<span className="playback-alternative-source__copy">
<span className="playback-alternative-source__title">
{selecting
? t('player.sourceFailureSwitching')
: t('player.sourceFailurePlayFrom', { server: source.serverLabel })}
</span>
<span className="playback-alternative-source__meta">
{sourceMeta(
source,
t('player.sourceFailureLocal'),
t('player.sourceFailureOnline'),
)}
</span>
</span>
</button>
);
})}
</div>
)}
{actionError && (
<div className="playback-alternative-modal__status playback-alternative-modal__status--error">
{t('player.sourceFailurePlayError')}
</div>
)}
</div>
<footer className="playback-alternative-modal__footer">
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
{t('player.sourceFailureKeep')}
</button>
</footer>
</div>
</div>,
document.body,
);
}
@@ -94,6 +94,7 @@ import {
queueItemIdentityKey,
sameQueueTrack,
} from '@/features/playback/utils/playback/queueIdentity';
import { reportPlaybackSourceFailure } from '@/features/playback/store/playbackAlternativeStore';
// Silence-aware crossfade (A-tail): guards the early advance to once per play
// generation so a single playback instance triggers at most one trim-advance
@@ -541,12 +542,16 @@ export function handleAudioError(message: string): void {
void playbackReportStopped();
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
showToast(`Couldn't play track. ${detail}`, 8000, 'error');
const gen = getPlayGeneration();
const store = usePlayerStore.getState();
usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
usePlayerStore.getState().next(false);
}, 1500);
reportPlaybackSourceFailure({
generation: gen,
queueIndex: store.queueIndex,
queueItems: store.queueItems,
track: store.currentTrack,
detail,
});
}
+16 -7
View File
@@ -91,6 +91,10 @@ import {
clearSeekTarget,
setSeekTarget,
} from '@/features/playback/store/seekTargetState';
import {
dismissPlaybackSourceFailure,
reportPlaybackSourceFailure,
} from '@/features/playback/store/playbackAlternativeStore';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
@@ -114,8 +118,8 @@ type GetState = () => PlayerState;
* The play body itself: clears all scheduled timers + seek state,
* resolves the URL, updates store + normalization snapshot
* optimistically, invokes the Rust engine, and on success seeks to
* the visual target if there was a pending one. Falls back to
* `next(false)` 500 ms after an `audio_play` failure. Same-track
* the visual target if there was a pending one. An `audio_play` failure leaves
* the queue source untouched and opens the explicit alternative-source flow. Same-track
* replays first flush the previous play's `stream_completed_cache`
* to hot disk so `fetch_data` doesn't re-run an HTTP range request.
*/
@@ -241,6 +245,7 @@ export function runPlayTrack(
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = bumpPlayGeneration();
dismissPlaybackSourceFailure();
clearInterruptHandoff();
setIsAudioPaused(false);
clearPreloadingIds(); // new track — allow fresh preload for next
@@ -593,11 +598,15 @@ export function runPlayTrack(
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
get().next(false);
}, 500);
set({ isPlaying: false, isPlaybackBuffering: false });
const failed = get();
reportPlaybackSourceFailure({
generation: gen,
queueIndex: failed.queueIndex,
queueItems: failed.queueItems,
track: failed.currentTrack,
detail: String(err),
});
});
};
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { waitFor } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
resolveSources: vi.fn(),
}));
vi.mock('@/lib/api/library', () => ({
libraryResolveEntitySources: mocks.resolveSources,
}));
import {
_resetPlaybackAlternativeStoreForTest,
reportPlaybackSourceFailure,
usePlaybackAlternativeStore,
} from './playbackAlternativeStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { makeServer, makeTrack } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
const source = (serverId: string, id: string) => ({
serverId,
id,
libraryId: '',
priority: 0,
durationSec: 180,
suffix: 'flac',
bitRate: 1_000,
sizeBytes: 30_000_000,
starredAt: null,
userRating: null,
});
beforeEach(() => {
resetAuthStore();
_resetPlaybackAlternativeStoreForTest();
Object.values(mocks).forEach(mock => mock.mockReset());
});
describe('reportPlaybackSourceFailure', () => {
it('deduplicates one play generation and filters out the failed concrete source', async () => {
const serverA = makeServer({ id: 'srv-a', name: 'Alpha', url: 'https://a.test' });
const serverB = makeServer({ id: 'srv-b', name: 'Beta', url: 'https://b.test' });
const serverC = makeServer({ id: 'srv-c', name: 'Gamma', url: 'https://c.test' });
useAuthStore.setState({
servers: [serverA, serverB, serverC],
activeServerId: serverA.id,
libraryBrowseServerIds: [serverA.id, serverB.id, serverC.id],
libraryBrowseSelectionByServer: {
[serverA.id]: ['lib-a'],
[serverB.id]: [],
[serverC.id]: ['lib-c'],
},
});
mocks.resolveSources.mockResolvedValue([
source(serverA.id, 'failed'),
source(serverB.id, 'remote-copy'),
source(serverC.id, 'unreachable-copy'),
source(serverC.id, 'local-copy'),
]);
const failedTrack = makeTrack({ id: 'failed', serverId: serverA.id });
const args = {
generation: 4,
queueIndex: 0,
queueItems: [{ serverId: 'a.test', trackId: 'failed' }],
track: failedTrack,
detail: 'decode error',
};
reportPlaybackSourceFailure(args);
reportPlaybackSourceFailure(args);
await waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
expect(mocks.resolveSources).toHaveBeenCalledOnce();
expect(mocks.resolveSources).toHaveBeenCalledWith('a.test', {
entityType: 'track',
anchorServerId: 'a.test',
anchorId: 'failed',
scopes: [
{ serverId: serverA.id, libraryId: 'lib-a' },
{ serverId: serverB.id, libraryId: null },
{ serverId: serverC.id, libraryId: 'lib-c' },
],
});
expect(usePlaybackAlternativeStore.getState().sources.map(candidate => candidate.id)).toEqual([
'remote-copy',
'unreachable-copy',
'local-copy',
]);
});
it('allows the same frozen ref to report again in a new play generation', async () => {
mocks.resolveSources.mockResolvedValue([]);
const track = makeTrack({ id: 'failed', serverId: 'srv-a' });
const base = {
queueIndex: 0,
queueItems: [{ serverId: 'srv-a', trackId: 'failed' }],
track,
detail: 'decode error',
};
reportPlaybackSourceFailure({ ...base, generation: 1 });
await waitFor(() => expect(usePlaybackAlternativeStore.getState().status).toBe('ready'));
reportPlaybackSourceFailure({ ...base, generation: 2 });
await waitFor(() => expect(mocks.resolveSources).toHaveBeenCalledTimes(2));
expect(usePlaybackAlternativeStore.getState().failure?.generation).toBe(2);
});
});
@@ -0,0 +1,122 @@
import { create } from 'zustand';
import type { LibraryEntitySourceDto } from '@/lib/api/library';
import { libraryResolveEntitySources } from '@/lib/api/library';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { deriveLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import { sameQueueItemRef } from '@/features/playback/utils/playback/queueIdentity';
export interface PlaybackSourceFailure {
key: string;
generation: number;
queueIndex: number;
expectedRef: QueueItemRef;
track: Track;
detail: string;
}
interface PlaybackAlternativeState {
failure: PlaybackSourceFailure | null;
status: 'idle' | 'loading' | 'ready' | 'error';
sources: LibraryEntitySourceDto[];
selectingKey: string | null;
actionError: string | null;
close: () => void;
}
let lastFailureKey: string | null = null;
export const usePlaybackAlternativeStore = create<PlaybackAlternativeState>(set => ({
failure: null,
status: 'idle',
sources: [],
selectingKey: null,
actionError: null,
close: () => set({ failure: null, status: 'idle', sources: [], selectingKey: null, actionError: null }),
}));
function failureKey(generation: number, queueIndex: number, ref: QueueItemRef): string {
return `${generation}:${queueIndex}:${ref.serverId}:${ref.trackId}`;
}
export function reportPlaybackSourceFailure(args: {
generation: number;
queueIndex: number;
queueItems: QueueItemRef[];
track: Track | null;
detail: string;
}): void {
const expectedRef = args.queueItems[args.queueIndex];
if (!expectedRef || !args.track) return;
const key = failureKey(args.generation, args.queueIndex, expectedRef);
if (lastFailureKey === key) return;
lastFailureKey = key;
const failure: PlaybackSourceFailure = {
key,
generation: args.generation,
queueIndex: args.queueIndex,
expectedRef: { ...expectedRef },
track: { ...args.track },
detail: args.detail,
};
usePlaybackAlternativeStore.setState({
failure,
status: 'loading',
sources: [],
selectingKey: null,
actionError: null,
});
const auth = useAuthStore.getState();
const configuredScope = deriveLibraryBrowseScope(auth, new Set()).pairs;
const scopes = configuredScope.length > 0
? configuredScope
: [{ serverId: expectedRef.serverId, libraryId: null }];
void libraryResolveEntitySources(expectedRef.serverId, {
entityType: 'track',
anchorServerId: expectedRef.serverId,
anchorId: expectedRef.trackId,
scopes,
}).then(sources => {
if (usePlaybackAlternativeStore.getState().failure?.key !== key) return;
const alternatives = sources
.filter(source => !sameQueueItemRef(
{ serverId: source.serverId, trackId: source.id },
expectedRef,
));
usePlaybackAlternativeStore.setState({ status: 'ready', sources: alternatives });
}).catch(error => {
console.error('[psysonic] alternative source lookup failed:', error);
if (usePlaybackAlternativeStore.getState().failure?.key !== key) return;
usePlaybackAlternativeStore.setState({ status: 'error', sources: [] });
});
}
export function setPlaybackAlternativeSelecting(source: LibraryEntitySourceDto | null): void {
usePlaybackAlternativeStore.setState({
selectingKey: source ? `${source.serverId}:${source.id}` : null,
actionError: null,
});
}
export function setPlaybackAlternativeActionError(): void {
usePlaybackAlternativeStore.setState({ selectingKey: null, actionError: 'play_failed' });
}
export function dismissPlaybackSourceFailure(): void {
usePlaybackAlternativeStore.getState().close();
}
export function _resetPlaybackAlternativeStoreForTest(): void {
lastFailureKey = null;
usePlaybackAlternativeStore.setState({
failure: null,
status: 'idle',
sources: [],
selectingKey: null,
actionError: null,
});
}
@@ -64,6 +64,7 @@ import {
setBytePreloadingRequest,
} from '@/features/playback/store/gaplessPreloadState';
import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity';
import { usePlaybackAlternativeStore } from '@/features/playback/store/playbackAlternativeStore';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -76,6 +77,7 @@ function stubPlaybackInvokes(): void {
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
onInvoke('library_resolve_entity_sources', () => []);
}
let cleanupListeners: (() => void) | null = null;
@@ -242,6 +244,29 @@ describe('audio:ended', () => {
});
});
describe('audio:error', () => {
it('keeps the failed queue slot selected and opens the alternative-source flow', async () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
const next = vi.fn();
usePlayerStore.setState({ isPlaying: true, next });
emitTauriEvent('audio:error', 'decoder failed');
vi.advanceTimersByTime(2_000);
await Promise.resolve();
const player = usePlayerStore.getState();
expect(next).not.toHaveBeenCalled();
expect(player.queueIndex).toBe(0);
expect(player.currentTrack?.id).toBe(queue[0].id);
expect(player.isPlaying).toBe(false);
expect(usePlaybackAlternativeStore.getState().failure).toEqual(expect.objectContaining({
queueIndex: 0,
detail: 'decoder failed',
}));
});
});
describe('audio preload events', () => {
it('does not let a stale cancellation clear a newer owner-qualified request', () => {
const identity = queueTrackIdentityKey('shared', 'srv-b');
@@ -58,6 +58,7 @@ import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeServer, makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
import { usePlaybackAlternativeStore } from '@/features/playback/store/playbackAlternativeStore';
function stubPlaybackInvokes(): void {
onInvoke('audio_play', () => undefined);
@@ -70,6 +71,7 @@ function stubPlaybackInvokes(): void {
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
onInvoke('library_resolve_entity_sources', () => []);
}
beforeEach(() => {
@@ -260,6 +262,39 @@ describe('mixed-server play selection', () => {
});
});
describe('audio_play failure', () => {
it('does not auto-skip and reports the frozen queue source', async () => {
const server = makeServer({ id: 'srv-a', url: 'https://a.test' });
useAuthStore.setState({
servers: [server],
activeServerId: server.id,
libraryBrowseServerIds: [server.id],
});
const queue = makeTracks(2, index => ({
id: `track-${index}`,
serverId: server.id,
}));
seedQueue(queue, { index: 0, currentTrack: queue[0], serverId: server.id });
const next = vi.fn();
usePlayerStore.setState({ next });
onInvoke('audio_play', () => { throw new Error('engine rejected source'); });
usePlayerStore.getState().playTrack(queue[0], undefined, true, false, 0);
await vi.runAllTimersAsync();
await Promise.resolve();
const player = usePlayerStore.getState();
expect(next).not.toHaveBeenCalled();
expect(player.queueIndex).toBe(0);
expect(player.currentTrack?.id).toBe('track-0');
expect(usePlaybackAlternativeStore.getState().failure).toEqual(expect.objectContaining({
queueIndex: 0,
expectedRef: { serverId: 'a.test', trackId: 'track-0' },
detail: 'Error: engine rejected source',
}));
});
});
describe('previous', () => {
it('restarts the current track when currentTime > 3 s', () => {
const queue = makeTracks(3);
@@ -294,6 +294,46 @@ describe('mixed-server queue identity', () => {
});
});
describe('replaceQueueItemSource', () => {
it('replaces only the matching frozen slot and preserves queue flags', () => {
const queue = makeTracks(3, index => ({
id: `track-${index}`,
serverId: 'srv-a',
...(index === 1 ? { playNextAdded: true } : {}),
}));
seedQueue(queue, { index: 1, currentTrack: queue[1] });
const before = usePlayerStore.getState().queueItems;
const expected = before[1]!;
const replaced = usePlayerStore.getState().replaceQueueItemSource(
1,
expected,
{ serverId: 'srv-b', trackId: 'alternative', playNextAdded: true },
);
const after = usePlayerStore.getState().queueItems;
expect(replaced).toBe(true);
expect(after[0]).toEqual(before[0]);
expect(after[1]).toEqual({ serverId: 'srv-b', trackId: 'alternative', playNextAdded: true });
expect(after[2]).toEqual(before[2]);
});
it('refuses to replace a slot that no longer matches the captured ref', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
const before = usePlayerStore.getState().queueItems;
const replaced = usePlayerStore.getState().replaceQueueItemSource(
0,
{ serverId: before[0]!.serverId, trackId: 'stale-id' },
{ serverId: 'srv-b', trackId: 'alternative' },
);
expect(replaced).toBe(false);
expect(usePlayerStore.getState().queueItems).toEqual(before);
});
});
describe('removeTrack', () => {
it('removes the track at the given index', () => {
const tracks = makeTracks(3);
@@ -131,6 +131,8 @@ export interface PlayerState {
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
/** Replace one frozen queue slot only when its concrete owner/id still match. */
replaceQueueItemSource: (index: number, expected: QueueItemRef, replacement: QueueItemRef) => boolean;
shuffleQueue: () => void;
/** Shuffle only the tracks after the current one — leaves played history intact. */
shuffleUpcomingQueue: () => void;
@@ -38,6 +38,7 @@ import { persistShuffleModeSnapshot } from '@/features/playback/store/shuffleMod
import {
queueItemIdentityKey,
queueItemRefMatchesTrack,
sameQueueItemRef,
} from '@/features/playback/utils/playback/queueIdentity';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
@@ -85,8 +86,27 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
| 'shuffleUpcomingQueue'
| 'toggleShuffleMode'
| 'removeTrack'
| 'replaceQueueItemSource'
> {
return {
replaceQueueItemSource: (index, expected, replacement) => {
const state = get();
const current = state.queueItems[index];
if (!current || !sameQueueItemRef(current, expected)) return false;
pushQueueUndoFromGetter(get);
const nextItems = [...state.queueItems];
nextItems[index] = { ...replacement };
set({ queueItems: nextItems });
syncUserQueueMutationToServer(
state.queueItems,
nextItems,
state.currentTrack,
state.currentTime,
);
return true;
},
/**
* Persistent shuffle: reorders the queue itself and remembers the order it
* came from, so switching it off restores that order. Rationale for
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
resolveBatch: vi.fn(),
getCachedTrack: vi.fn(),
}));
vi.mock('@/features/playback/store/queueTrackResolver', async importOriginal => ({
...(await importOriginal<typeof import('@/features/playback/store/queueTrackResolver')>()),
resolveBatch: mocks.resolveBatch,
getCachedTrack: mocks.getCachedTrack,
}));
import { selectPlaybackAlternative } from './selectPlaybackAlternative';
import { usePlaybackAlternativeStore } from './playbackAlternativeStore';
import { usePlayerStore } from './playerStore';
import { _resetEngineStateForTest, bumpPlayGeneration } from './engineState';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { makeServer, makeTrack } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
import type { PlaybackAlternativeSource } from '@/features/playback/utils/playback/availablePlaybackAlternativeSources';
const serverA = makeServer({ id: 'srv-a', url: 'https://a.test' });
const serverB = makeServer({ id: 'srv-b', url: 'https://b.test' });
const expectedRef = { serverId: 'a.test', trackId: 'failed', playNextAdded: true };
const alternative: PlaybackAlternativeSource = {
serverId: serverB.id,
id: 'replacement',
libraryId: '',
priority: 0,
durationSec: 180,
suffix: 'flac',
bitRate: 1_000,
sizeBytes: 30_000_000,
starredAt: null,
userRating: null,
local: false,
serverLabel: 'B',
};
function seedFailure(generation: number): void {
usePlaybackAlternativeStore.setState({
failure: {
key: `${generation}:1:a.test:failed`,
generation,
queueIndex: 1,
expectedRef,
track: makeTrack({ id: 'failed', serverId: serverA.id }),
detail: 'decode error',
},
status: 'ready',
sources: [alternative],
selectingKey: null,
actionError: null,
});
}
beforeEach(() => {
vi.useFakeTimers();
resetAuthStore();
resetPlayerStore();
_resetEngineStateForTest();
Object.values(mocks).forEach(mock => mock.mockReset());
mocks.resolveBatch.mockResolvedValue(undefined);
useAuthStore.setState({ servers: [serverA, serverB], activeServerId: serverA.id });
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
describe('selectPlaybackAlternative', () => {
it('revalidates the frozen slot after asynchronous resolution', async () => {
let finishResolve!: () => void;
mocks.resolveBatch.mockImplementation(() => new Promise<void>(resolve => { finishResolve = resolve; }));
mocks.getCachedTrack.mockReturnValue(makeTrack({ id: alternative.id, serverId: serverB.id }));
const generation = bumpPlayGeneration();
seedFailure(generation);
const playTrack = vi.fn();
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 'head' },
expectedRef,
],
queueIndex: 1,
playTrack,
});
const selection = selectPlaybackAlternative(alternative);
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 'head' },
{ serverId: 'a.test', trackId: 'user-selected-another-track' },
],
});
finishResolve();
await expect(selection).resolves.toBe(false);
expect(playTrack).not.toHaveBeenCalled();
expect(usePlaybackAlternativeStore.getState().actionError).toBe('play_failed');
});
it('replaces only the captured slot and starts the concrete owner', async () => {
const selectedTrack = makeTrack({ id: alternative.id, serverId: serverB.id, title: 'Replacement' });
mocks.getCachedTrack.mockReturnValue(selectedTrack);
const generation = bumpPlayGeneration();
seedFailure(generation);
const playTrack = vi.fn();
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 'head' },
expectedRef,
{ serverId: 'a.test', trackId: 'tail' },
],
queueIndex: 1,
currentTrack: makeTrack({ id: 'failed', serverId: serverA.id }),
playTrack,
});
await expect(selectPlaybackAlternative(alternative)).resolves.toBe(true);
expect(usePlayerStore.getState().queueItems).toEqual([
{ serverId: 'a.test', trackId: 'head' },
{ serverId: 'b.test', trackId: 'replacement', playNextAdded: true },
{ serverId: 'a.test', trackId: 'tail' },
]);
expect(playTrack).toHaveBeenCalledWith(
expect.objectContaining({ id: 'replacement', serverId: serverB.id }),
undefined,
true,
false,
1,
);
expect(usePlaybackAlternativeStore.getState().failure).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { getPlayGeneration } from '@/features/playback/store/engineState';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
dismissPlaybackSourceFailure,
setPlaybackAlternativeActionError,
setPlaybackAlternativeSelecting,
usePlaybackAlternativeStore,
} from '@/features/playback/store/playbackAlternativeStore';
import { getCachedTrack, resolveBatch } from '@/features/playback/store/queueTrackResolver';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
import { sameQueueItemRef } from '@/features/playback/utils/playback/queueIdentity';
import type { QueueItemRef } from '@/lib/media/trackTypes';
import type { LibraryEntitySourceDto } from '@/lib/api/library';
function replacementRef(expected: QueueItemRef, source: LibraryEntitySourceDto): QueueItemRef {
return {
serverId: canonicalQueueServerKey(source.serverId),
trackId: source.id,
autoAdded: expected.autoAdded,
radioAdded: expected.radioAdded,
playNextAdded: expected.playNextAdded,
};
}
export async function selectPlaybackAlternative(source: LibraryEntitySourceDto): Promise<boolean> {
const failure = usePlaybackAlternativeStore.getState().failure;
if (!failure || getPlayGeneration() !== failure.generation) return false;
const player = usePlayerStore.getState();
const currentRef = player.queueItems[failure.queueIndex];
if (!currentRef || !sameQueueItemRef(currentRef, failure.expectedRef)) {
setPlaybackAlternativeActionError();
return false;
}
const nextRef = replacementRef(failure.expectedRef, source);
setPlaybackAlternativeSelecting(source);
try {
await resolveBatch([nextRef]);
const track = getCachedTrack(nextRef);
if (!track || getPlayGeneration() !== failure.generation) {
setPlaybackAlternativeActionError();
return false;
}
const latest = usePlayerStore.getState();
const latestRef = latest.queueItems[failure.queueIndex];
if (!latestRef || !sameQueueItemRef(latestRef, failure.expectedRef)) {
setPlaybackAlternativeActionError();
return false;
}
if (!latest.replaceQueueItemSource(failure.queueIndex, failure.expectedRef, nextRef)) {
setPlaybackAlternativeActionError();
return false;
}
dismissPlaybackSourceFailure();
usePlayerStore.getState().playTrack(
{ ...track, serverId: source.serverId },
undefined,
true,
false,
failure.queueIndex,
);
return true;
} catch (error) {
console.error('[psysonic] alternative source play failed:', error);
setPlaybackAlternativeActionError();
return false;
}
}
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
hasLocalPlaybackUrl: vi.fn(),
isServerReachable: vi.fn(),
}));
vi.mock('@/store/localPlaybackResolve', () => ({
hasLocalPlaybackUrl: mocks.hasLocalPlaybackUrl,
}));
vi.mock('@/lib/network/subsonicNetworkGuard', () => ({
isSubsonicServerReachable: mocks.isServerReachable,
}));
import { availablePlaybackAlternativeSources } from './availablePlaybackAlternativeSources';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { makeServer } from '@/test/helpers/factories';
import { useAuthStore } from '@/store/authStore';
const source = (serverId: string, id: string) => ({
serverId,
id,
libraryId: '',
priority: 0,
durationSec: 180,
suffix: 'flac',
bitRate: 1_000,
sizeBytes: 30_000_000,
starredAt: null,
userRating: null,
});
beforeEach(() => {
resetAuthStore();
Object.values(mocks).forEach(mock => mock.mockReset());
});
describe('availablePlaybackAlternativeSources', () => {
it('keeps reachable or local copies and labels their server profiles', () => {
const serverB = makeServer({ id: 'srv-b', name: 'Beta', url: 'https://b.test' });
const serverC = makeServer({ id: 'srv-c', name: 'Gamma', url: 'https://c.test' });
useAuthStore.setState({ servers: [serverB, serverC], activeServerId: serverB.id });
mocks.hasLocalPlaybackUrl.mockImplementation((id: string) => id === 'local-copy');
mocks.isServerReachable.mockImplementation((serverId: string) => serverId !== serverC.id);
const available = availablePlaybackAlternativeSources([
source(serverB.id, 'remote-copy'),
source(serverC.id, 'unreachable-copy'),
source(serverC.id, 'local-copy'),
]);
expect(available).toEqual([
expect.objectContaining({ id: 'remote-copy', local: false, serverLabel: 'Beta' }),
expect.objectContaining({ id: 'local-copy', local: true, serverLabel: 'Gamma' }),
]);
});
});
@@ -0,0 +1,29 @@
import type { LibraryEntitySourceDto } from '@/lib/api/library';
import { isSubsonicServerReachable } from '@/lib/network/subsonicNetworkGuard';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import { hasLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { useAuthStore } from '@/store/authStore';
export interface PlaybackAlternativeSource extends LibraryEntitySourceDto {
local: boolean;
serverLabel: string;
}
export function availablePlaybackAlternativeSources(
sources: LibraryEntitySourceDto[],
): PlaybackAlternativeSource[] {
const auth = useAuthStore.getState();
return sources
.map(source => {
const local = hasLocalPlaybackUrl(source.id, source.serverId);
const profileId = resolveServerIdForIndexKey(source.serverId) || source.serverId;
const server = auth.servers.find(candidate => candidate.id === profileId);
return {
...source,
local,
serverLabel: server ? serverListDisplayLabel(server, auth.servers) : source.serverId,
};
})
.filter(source => source.local || isSubsonicServerReachable(source.serverId));
}
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Източник: YouLyPlus',
showDuration: 'Покажи времетраенето',
showRemainingTime: 'Покажи оставащото време',
sourceFailureTitle: 'Неуспешно възпроизвеждане на {{title}}',
sourceFailureBody: 'Източникът в опашката не е променен. Изберете друго налично копие.',
sourceFailureDetail: 'Грешка при възпроизвеждане: {{detail}}',
sourceFailureSearching: 'Търсене на друг избран източник…',
sourceFailureLoadError: 'Алтернативните източници не могат да бъдат проверени.',
sourceFailureNone: 'Няма налична алтернатива за възпроизвеждане от избраните сървъри.',
sourceFailurePlayError: 'Този източник не може да бъде зареден. Опашката не е променена.',
sourceFailurePlayFrom: 'Пусни от {{server}}',
sourceFailureLocal: 'Изтеглено',
sourceFailureOnline: 'Онлайн',
sourceFailureSwitching: 'Превключване…',
sourceFailureKeep: 'Запази текущия източник',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Quelle: YouLyPlus',
showDuration: 'Dauer anzeigen',
showRemainingTime: 'Verbleibende Zeit anzeigen',
sourceFailureTitle: '{{title}} konnte nicht abgespielt werden',
sourceFailureBody: 'Die Quelle in der Warteschlange blieb unverändert. Wähle eine andere verfügbare Kopie.',
sourceFailureDetail: 'Wiedergabefehler: {{detail}}',
sourceFailureSearching: 'Eine andere ausgewählte Quelle wird gesucht…',
sourceFailureLoadError: 'Alternative Quellen konnten nicht geprüft werden.',
sourceFailureNone: 'Auf den ausgewählten Servern ist keine abspielbare Alternative verfügbar.',
sourceFailurePlayError: 'Diese Quelle konnte nicht geladen werden. Die Warteschlange blieb unverändert.',
sourceFailurePlayFrom: 'Von {{server}} abspielen',
sourceFailureLocal: 'Heruntergeladen',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Wechseln…',
sourceFailureKeep: 'Aktuelle Quelle behalten',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Source: YouLyPlus',
showDuration: 'Show duration',
showRemainingTime: 'Show remaining time',
sourceFailureTitle: 'Couldnt play {{title}}',
sourceFailureBody: 'The queued source was left unchanged. Choose another available copy to try again.',
sourceFailureDetail: 'Playback error: {{detail}}',
sourceFailureSearching: 'Looking for another selected source…',
sourceFailureLoadError: 'Couldnt check alternative sources.',
sourceFailureNone: 'No playable alternative is available from the selected servers.',
sourceFailurePlayError: 'Couldnt load that source. The queue was not changed.',
sourceFailurePlayFrom: 'Play from {{server}}',
sourceFailureLocal: 'Downloaded',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Switching…',
sourceFailureKeep: 'Keep current source',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Fuente: YouLyPlus',
showDuration: 'Mostrar duración',
showRemainingTime: 'Mostrar tiempo restante',
sourceFailureTitle: 'No se pudo reproducir {{title}}',
sourceFailureBody: 'La fuente en cola no se modificó. Elige otra copia disponible para intentarlo de nuevo.',
sourceFailureDetail: 'Error de reproducción: {{detail}}',
sourceFailureSearching: 'Buscando otra fuente seleccionada…',
sourceFailureLoadError: 'No se pudieron comprobar las fuentes alternativas.',
sourceFailureNone: 'No hay ninguna alternativa reproducible en los servidores seleccionados.',
sourceFailurePlayError: 'No se pudo cargar esa fuente. La cola no se modificó.',
sourceFailurePlayFrom: 'Reproducir desde {{server}}',
sourceFailureLocal: 'Descargado',
sourceFailureOnline: 'En línea',
sourceFailureSwitching: 'Cambiando…',
sourceFailureKeep: 'Mantener la fuente actual',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Source : YouLyPlus',
showDuration: 'Afficher la durée',
showRemainingTime: 'Afficher le temps restant',
sourceFailureTitle: 'Impossible de lire {{title}}',
sourceFailureBody: "La source dans la file dattente na pas été modifiée. Choisissez une autre copie disponible.",
sourceFailureDetail: 'Erreur de lecture : {{detail}}',
sourceFailureSearching: 'Recherche dune autre source sélectionnée…',
sourceFailureLoadError: 'Impossible de vérifier les sources alternatives.',
sourceFailureNone: 'Aucune alternative lisible nest disponible sur les serveurs sélectionnés.',
sourceFailurePlayError: "Impossible de charger cette source. La file dattente na pas été modifiée.",
sourceFailurePlayFrom: 'Lire depuis {{server}}',
sourceFailureLocal: 'Téléchargé',
sourceFailureOnline: 'En ligne',
sourceFailureSwitching: 'Changement…',
sourceFailureKeep: 'Conserver la source actuelle',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Forrás: YouLyPlus',
showDuration: 'Hossz megjelenítése',
showRemainingTime: 'Hátralévő idő megjelenítése',
sourceFailureTitle: 'A(z) {{title}} nem játszható le',
sourceFailureBody: 'A sorban lévő forrás nem változott. Válassz egy másik elérhető példányt.',
sourceFailureDetail: 'Lejátszási hiba: {{detail}}',
sourceFailureSearching: 'Másik kiválasztott forrás keresése…',
sourceFailureLoadError: 'Az alternatív források nem ellenőrizhetők.',
sourceFailureNone: 'A kiválasztott szervereken nincs lejátszható alternatíva.',
sourceFailurePlayError: 'Ez a forrás nem tölthető be. A sor nem változott.',
sourceFailurePlayFrom: 'Lejátszás innen: {{server}}',
sourceFailureLocal: 'Letöltve',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Váltás…',
sourceFailureKeep: 'Jelenlegi forrás megtartása',
};
+13 -1
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Fonte: YouLyPlus',
showDuration: 'Mostra durata',
showRemainingTime: 'Mostra tempo rimanente',
};
sourceFailureTitle: 'Impossibile riprodurre {{title}}',
sourceFailureBody: 'La fonte in coda non è stata modificata. Scegli unaltra copia disponibile.',
sourceFailureDetail: 'Errore di riproduzione: {{detail}}',
sourceFailureSearching: 'Ricerca di unaltra fonte selezionata…',
sourceFailureLoadError: 'Impossibile controllare le fonti alternative.',
sourceFailureNone: 'Nessuna alternativa riproducibile è disponibile sui server selezionati.',
sourceFailurePlayError: 'Impossibile caricare quella fonte. La coda non è stata modificata.',
sourceFailurePlayFrom: 'Riproduci da {{server}}',
sourceFailureLocal: 'Scaricato',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Cambio…',
sourceFailureKeep: 'Mantieni la fonte attuale',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'ソース: YouLyPlus',
showDuration: '長さを表示',
showRemainingTime: '残り時間を表示',
sourceFailureTitle: '{{title}} を再生できませんでした',
sourceFailureBody: 'キュー内のソースは変更されていません。別の利用可能なコピーを選択してください。',
sourceFailureDetail: '再生エラー: {{detail}}',
sourceFailureSearching: '選択済みの別ソースを検索中…',
sourceFailureLoadError: '代替ソースを確認できませんでした。',
sourceFailureNone: '選択したサーバーに再生可能な代替ソースがありません。',
sourceFailurePlayError: 'そのソースを読み込めませんでした。キューは変更されていません。',
sourceFailurePlayFrom: '{{server}} から再生',
sourceFailureLocal: 'ダウンロード済み',
sourceFailureOnline: 'オンライン',
sourceFailureSwitching: '切り替え中…',
sourceFailureKeep: '現在のソースを維持',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Kilde: YouLyPlus',
showDuration: 'Vis varighet',
showRemainingTime: 'Vis gjenværende tid',
sourceFailureTitle: 'Kunne ikke spille {{title}}',
sourceFailureBody: 'Kilden i køen ble ikke endret. Velg en annen tilgjengelig kopi.',
sourceFailureDetail: 'Avspillingsfeil: {{detail}}',
sourceFailureSearching: 'Ser etter en annen valgt kilde…',
sourceFailureLoadError: 'Kunne ikke kontrollere alternative kilder.',
sourceFailureNone: 'Ingen spillbar alternativ kilde er tilgjengelig fra de valgte serverne.',
sourceFailurePlayError: 'Kunne ikke laste den kilden. Køen ble ikke endret.',
sourceFailurePlayFrom: 'Spill fra {{server}}',
sourceFailureLocal: 'Lastet ned',
sourceFailureOnline: 'På nett',
sourceFailureSwitching: 'Bytter…',
sourceFailureKeep: 'Behold gjeldende kilde',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Bron: YouLyPlus',
showDuration: 'Toon duur',
showRemainingTime: 'Toon resterende tijd',
sourceFailureTitle: 'Kan {{title}} niet afspelen',
sourceFailureBody: 'De bron in de wachtrij is niet gewijzigd. Kies een andere beschikbare kopie.',
sourceFailureDetail: 'Afspeelfout: {{detail}}',
sourceFailureSearching: 'Een andere geselecteerde bron zoeken…',
sourceFailureLoadError: 'Alternatieve bronnen konden niet worden gecontroleerd.',
sourceFailureNone: 'Er is geen afspeelbaar alternatief beschikbaar op de geselecteerde servers.',
sourceFailurePlayError: 'Die bron kon niet worden geladen. De wachtrij is niet gewijzigd.',
sourceFailurePlayFrom: 'Afspelen vanaf {{server}}',
sourceFailureLocal: 'Gedownload',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Overschakelen…',
sourceFailureKeep: 'Huidige bron behouden',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Źródło: YouLyPlus',
showDuration: 'Pokaż czas trwania',
showRemainingTime: 'Pokaż pozostały czas',
sourceFailureTitle: 'Nie można odtworzyć {{title}}',
sourceFailureBody: 'Źródło w kolejce nie zostało zmienione. Wybierz inną dostępną kopię.',
sourceFailureDetail: 'Błąd odtwarzania: {{detail}}',
sourceFailureSearching: 'Wyszukiwanie innego wybranego źródła…',
sourceFailureLoadError: 'Nie można sprawdzić alternatywnych źródeł.',
sourceFailureNone: 'Na wybranych serwerach nie ma dostępnej alternatywy do odtworzenia.',
sourceFailurePlayError: 'Nie można załadować tego źródła. Kolejka nie została zmieniona.',
sourceFailurePlayFrom: 'Odtwórz z {{server}}',
sourceFailureLocal: 'Pobrano',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Przełączanie…',
sourceFailureKeep: 'Zachowaj bieżące źródło',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: 'Sursa: YouLyPlus',
showDuration: 'Arată durata',
showRemainingTime: 'Arată timpul rămas',
sourceFailureTitle: 'Nu s-a putut reda {{title}}',
sourceFailureBody: 'Sursa din coadă nu a fost schimbată. Alege o altă copie disponibilă.',
sourceFailureDetail: 'Eroare de redare: {{detail}}',
sourceFailureSearching: 'Se caută o altă sursă selectată…',
sourceFailureLoadError: 'Nu s-au putut verifica sursele alternative.',
sourceFailureNone: 'Nu există nicio alternativă redabilă pe serverele selectate.',
sourceFailurePlayError: 'Sursa nu a putut fi încărcată. Coada nu a fost schimbată.',
sourceFailurePlayFrom: 'Redă de pe {{server}}',
sourceFailureLocal: 'Descărcat',
sourceFailureOnline: 'Online',
sourceFailureSwitching: 'Se schimbă…',
sourceFailureKeep: 'Păstrează sursa curentă',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
fsLyricsToggle: 'Текст в полноэкранном режиме',
showDuration: 'Показать длительность',
showRemainingTime: 'Показать оставшееся время',
sourceFailureTitle: 'Не удалось воспроизвести {{title}}',
sourceFailureBody: 'Источник в очереди не изменён. Выберите другую доступную копию.',
sourceFailureDetail: 'Ошибка воспроизведения: {{detail}}',
sourceFailureSearching: 'Поиск другого выбранного источника…',
sourceFailureLoadError: 'Не удалось проверить альтернативные источники.',
sourceFailureNone: 'На выбранных серверах нет доступной для воспроизведения альтернативы.',
sourceFailurePlayError: 'Не удалось загрузить этот источник. Очередь не изменена.',
sourceFailurePlayFrom: 'Воспроизвести с {{server}}',
sourceFailureLocal: 'Загружено',
sourceFailureOnline: 'В сети',
sourceFailureSwitching: 'Переключение…',
sourceFailureKeep: 'Оставить текущий источник',
};
+12
View File
@@ -68,4 +68,16 @@ export const player = {
lyricsSourceLyricsplus: '来源:YouLyPlus',
showDuration: '显示时长',
showRemainingTime: '显示剩余时间',
sourceFailureTitle: '无法播放 {{title}}',
sourceFailureBody: '队列中的来源未更改。请选择另一个可用副本重试。',
sourceFailureDetail: '播放错误:{{detail}}',
sourceFailureSearching: '正在查找其他已选来源…',
sourceFailureLoadError: '无法检查备用来源。',
sourceFailureNone: '所选服务器中没有可播放的备用来源。',
sourceFailurePlayError: '无法加载该来源。队列未更改。',
sourceFailurePlayFrom: '从 {{server}} 播放',
sourceFailureLocal: '已下载',
sourceFailureOnline: '在线',
sourceFailureSwitching: '正在切换…',
sourceFailureKeep: '保留当前来源',
};
+1
View File
@@ -21,6 +21,7 @@
@import './modal.css';
@import './ui-modal.css';
@import './playback-delay-sleep-delayed-start-modal.css';
@import './playback-alternative-modal.css';
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
@import './playback-buffering-overlay.css';
@import './playlist-edit-modal.css';
@@ -0,0 +1,159 @@
.playback-alternative-overlay {
align-items: center;
padding: var(--space-4);
}
.playback-alternative-modal {
width: min(520px, 100%);
max-height: min(78vh, 680px);
padding: var(--space-6);
}
.playback-alternative-modal:focus {
outline: none;
}
.playback-alternative-modal > .modal-close {
display: grid;
width: 44px;
height: 44px;
place-items: center;
}
.playback-alternative-modal__header {
display: flex;
gap: var(--space-3);
padding-right: var(--space-8);
}
.playback-alternative-modal__warning {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger);
}
.playback-alternative-modal__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.2rem;
}
.playback-alternative-modal__description {
margin: var(--space-1) 0 0;
color: var(--text-secondary);
line-height: 1.45;
}
.playback-alternative-modal__detail {
margin: var(--space-4) 0;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.playback-alternative-modal__body {
min-height: 72px;
}
.playback-alternative-modal__status {
display: flex;
align-items: center;
justify-content: center;
min-height: 72px;
gap: var(--space-2);
color: var(--text-secondary);
text-align: center;
}
.playback-alternative-modal__status--error {
color: var(--danger);
}
.playback-alternative-modal__spinner {
animation: spin 0.9s linear infinite;
}
.playback-alternative-modal__sources {
display: grid;
gap: var(--space-2);
}
.playback-alternative-source {
display: flex;
align-items: center;
width: 100%;
min-height: 56px;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
color: var(--text-primary);
text-align: left;
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
}
.playback-alternative-source:hover:not(:disabled) {
border-color: var(--accent);
background: var(--bg-hover);
transform: translateY(-1px);
}
.playback-alternative-source:disabled {
cursor: wait;
opacity: 0.68;
}
.playback-alternative-source:focus-visible {
outline: 2px solid var(--focus-ring-color, var(--accent));
outline-offset: 2px;
}
.playback-alternative-source__icon {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
.playback-alternative-source__copy {
display: grid;
min-width: 0;
gap: 2px;
}
.playback-alternative-source__title {
overflow: hidden;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.playback-alternative-source__meta {
color: var(--text-muted);
font-size: 0.8rem;
}
.playback-alternative-modal__footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-5);
}
@media (prefers-reduced-motion: reduce) {
.playback-alternative-source {
transition: none;
}
}
+2
View File
@@ -27,6 +27,7 @@ import { useAuthStore } from '@/store/authStore';
import { usePreviewStore } from '@/features/playback/store/previewStore';
import { useOrbitStore } from '@/features/orbit';
import { resetServerReachabilitySnapshot } from '@/lib/network/serverReachability';
import { _resetPlaybackAlternativeStoreForTest } from '@/features/playback/store/playbackAlternativeStore';
const INITIAL_PLAYER_STATE = usePlayerStore.getState();
const INITIAL_AUTH_STATE = useAuthStore.getState();
@@ -38,6 +39,7 @@ export function resetPlayerStore(): void {
// Module-scoped queue undo/redo stacks live outside the Zustand state.
_resetQueueUndoStacksForTest();
_resetTimelineSessionHistoryForTest();
_resetPlaybackAlternativeStoreForTest();
}
export function resetAuthStore(): void {