mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
feat(playback): choose alternative failed sources
This commit is contained in:
@@ -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: /couldn’t 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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: 'Запази текущия източник',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -68,4 +68,16 @@ export const player = {
|
||||
lyricsSourceLyricsplus: 'Source: YouLyPlus',
|
||||
showDuration: 'Show duration',
|
||||
showRemainingTime: 'Show remaining time',
|
||||
sourceFailureTitle: 'Couldn’t 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: 'Couldn’t check alternative sources.',
|
||||
sourceFailureNone: 'No playable alternative is available from the selected servers.',
|
||||
sourceFailurePlayError: 'Couldn’t load that source. The queue was not changed.',
|
||||
sourceFailurePlayFrom: 'Play from {{server}}',
|
||||
sourceFailureLocal: 'Downloaded',
|
||||
sourceFailureOnline: 'Online',
|
||||
sourceFailureSwitching: 'Switching…',
|
||||
sourceFailureKeep: 'Keep current source',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 d’attente n’a pas été modifiée. Choisissez une autre copie disponible.",
|
||||
sourceFailureDetail: 'Erreur de lecture : {{detail}}',
|
||||
sourceFailureSearching: 'Recherche d’une autre source sélectionnée…',
|
||||
sourceFailureLoadError: 'Impossible de vérifier les sources alternatives.',
|
||||
sourceFailureNone: 'Aucune alternative lisible n’est disponible sur les serveurs sélectionnés.',
|
||||
sourceFailurePlayError: "Impossible de charger cette source. La file d’attente n’a pas été modifiée.",
|
||||
sourceFailurePlayFrom: 'Lire depuis {{server}}',
|
||||
sourceFailureLocal: 'Téléchargé',
|
||||
sourceFailureOnline: 'En ligne',
|
||||
sourceFailureSwitching: 'Changement…',
|
||||
sourceFailureKeep: 'Conserver la source actuelle',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 un’altra copia disponibile.',
|
||||
sourceFailureDetail: 'Errore di riproduzione: {{detail}}',
|
||||
sourceFailureSearching: 'Ricerca di un’altra 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',
|
||||
};
|
||||
|
||||
@@ -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: '現在のソースを維持',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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ă',
|
||||
};
|
||||
|
||||
@@ -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: 'Оставить текущий источник',
|
||||
};
|
||||
|
||||
@@ -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: '保留当前来源',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user