fix(sync): let a phone pause the desktop, and let a Vibe follow the audio
Six bugs in the single transport and in how a browser registers as a device, then the feature the fourth one was hiding. A watching device keeps its own audio paused, so every control that read `isPlaying` from the store drew a Play button while the desktop played — and sent `play` when it was pressed. The transport now carries one `playing` value: the remote state while presses are being forwarded, the local one otherwise. Two tabs of one browser shared a stored device id, which made them one device that ran every command twice and played two copies of the audio. A device id is now held by whichever stream has it open: registration refuses to hand back a busy id, and each tab keeps its own in `sessionStorage`. A device that was only showing what another one plays still pointed an audio element at the stream, downloading tracks it would never play. It now loads nothing while the audio is elsewhere, and reloads the moment it comes back. Commands were accepted for an owner with no stream to receive them on, so a killed tab answered a press with a success it never got. Ownership outlives a closed stream deliberately; delivery does not. The event stream never called `reply.hijack()`, leaving Fastify waiting on a handler that resolves with nothing. And the Vibe: `setQueue` is an ownership handoff, so a snapshot from another device dropped the advance handler that asks the server for the next track. A session moved to a phone became a fixed list of the hundred tracks that happened to be synced. Vibe control now follows the audio — the snapshot carries the session id, the device losing the audio stops driving, and the one gaining it resumes the durable session and takes over replanning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
@@ -28,7 +28,7 @@ describe('AudioEngine', () => {
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, audioElsewhere: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,24 @@ describe('AudioEngine', () => {
|
||||
Object.defineProperty(audio, 'ended', { value: false, configurable: true });
|
||||
};
|
||||
|
||||
it('loads no stream while another device holds the audio, and loads one when it comes back', () => {
|
||||
usePlaybackStore.setState({
|
||||
audioElsewhere: true, isPlaying: false, prefetchNext: true,
|
||||
queue: [track('song'), track('next')], currentIndex: 0,
|
||||
});
|
||||
const { container } = render(<AudioEngine />);
|
||||
const [active, idle] = Array.from(container.querySelectorAll('audio'));
|
||||
|
||||
expect(active.getAttribute('src')).toBeNull();
|
||||
expect(idle.getAttribute('src')).toBeNull();
|
||||
// Mirroring is not playback: this device reports nothing to the session.
|
||||
expect(reportVibeEvent).not.toHaveBeenCalled();
|
||||
|
||||
act(() => usePlaybackStore.getState().setAudioElsewhere(false));
|
||||
|
||||
expect(active.src).toContain('/tracks/song/stream');
|
||||
});
|
||||
|
||||
it('buffers the next queued track into the idle element before the current one ends', () => {
|
||||
usePlaybackStore.setState({
|
||||
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
|
||||
|
||||
@@ -213,7 +213,7 @@ export const AudioEngine = () => {
|
||||
/** Warm the predicted next track into the idle element. */
|
||||
const prefetch = () => {
|
||||
const playback = store();
|
||||
if (!playback.prefetchNext) return;
|
||||
if (!playback.prefetchNext || playback.audioElsewhere) return;
|
||||
const nextId = predictNextTrackId();
|
||||
if (!nextId || nextId === loadedIdRef.current) return;
|
||||
const idleIdx = 1 - activeIdxRef.current;
|
||||
@@ -352,6 +352,20 @@ export const AudioEngine = () => {
|
||||
if (els.length === 0) return;
|
||||
|
||||
const applyTrack = (id: string | null) => {
|
||||
// Another device is playing and this one is only showing what it plays.
|
||||
// Pointing an element at the stream here would download the track — the
|
||||
// whole of it, once an earlier prefetch left that element on preload=auto
|
||||
// — for audio that is never heard. Drop the sources and follow along.
|
||||
if (usePlaybackStore.getState().audioElsewhere) {
|
||||
cancelJoin();
|
||||
for (const idx of [0, 1]) retire(idx);
|
||||
loadedIdRef.current = null;
|
||||
preparedRef.current = null;
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
earlyAdvanceRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (id === loadedIdRef.current) return;
|
||||
// A newer track change supersedes any tail still waiting to hand over.
|
||||
cancelJoin();
|
||||
@@ -424,8 +438,15 @@ export const AudioEngine = () => {
|
||||
};
|
||||
|
||||
// Apply the current value immediately, then subscribe to future changes.
|
||||
let lastElsewhere = usePlaybackStore.getState().audioElsewhere;
|
||||
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
|
||||
const unsub = usePlaybackStore.subscribe((state) => {
|
||||
if (state.audioElsewhere !== lastElsewhere) {
|
||||
lastElsewhere = state.audioElsewhere;
|
||||
// The audio just came back to this device. Nothing is loaded, and the
|
||||
// track id has not changed, so say so and let applyTrack load it.
|
||||
if (!lastElsewhere) loadedIdRef.current = null;
|
||||
}
|
||||
applyTrack(state.currentTrack?.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
@@ -438,6 +459,8 @@ export const AudioEngine = () => {
|
||||
|
||||
const apply = (isPlaying: boolean) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
// Nothing is loaded while another device holds the audio.
|
||||
if (usePlaybackStore.getState().audioElsewhere) return;
|
||||
if (isPlaying) {
|
||||
// Never resume a finished element. Between `ended` and the next source
|
||||
// being loaded, isPlaying is still true, and resuming here replays the
|
||||
@@ -488,6 +511,9 @@ export const AudioEngine = () => {
|
||||
|
||||
const apply = (position: number) => {
|
||||
const audio = els[activeIdxRef.current];
|
||||
// A mirrored position belongs to another device's playhead, and there is
|
||||
// no source loaded here to move anyway.
|
||||
if (usePlaybackStore.getState().audioElsewhere) return;
|
||||
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
|
||||
audio.currentTime = position;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
previous?.focus();
|
||||
};
|
||||
}, []);
|
||||
const { currentTrack, queue, isPlaying, position, duration } = usePlaybackStore();
|
||||
const { currentTrack, queue, position, duration } = usePlaybackStore();
|
||||
const transport = useTransport();
|
||||
|
||||
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
@@ -110,11 +110,11 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
<button onClick={transport.prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
|
||||
<button
|
||||
onClick={transport.toggle}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={transport.playing ? 'Pause' : 'Play'}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={transport.next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface PlaybackBarProps {
|
||||
}
|
||||
|
||||
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
|
||||
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||
const { currentTrack, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const transport = useTransport();
|
||||
|
||||
@@ -90,9 +90,9 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
onClick={transport.toggle}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={transport.playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={transport.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
|
||||
<SkipForward size={20} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { playbackSyncService, type PlaybackSnapshot, type PlaybackSyncEvent } from '../services/playbackSync';
|
||||
import { usePlaybackSync } from './usePlaybackSync';
|
||||
|
||||
@@ -9,6 +10,12 @@ const OTHER_DEVICE = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
let emit: ((event: PlaybackSyncEvent) => void) | null = null;
|
||||
|
||||
const { adoptVibeSession, releaseVibeDriving } = vi.hoisted(() => ({
|
||||
adoptVibeSession: vi.fn(async () => true),
|
||||
releaseVibeDriving: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/vibeSession', () => ({ adoptVibeSession, releaseVibeDriving }));
|
||||
|
||||
vi.mock('../services/playbackSync', () => ({
|
||||
storedDeviceId: () => null,
|
||||
playbackSyncService: {
|
||||
@@ -34,6 +41,7 @@ function snapshot(over: Partial<PlaybackSnapshot> = {}): PlaybackSnapshot {
|
||||
return {
|
||||
deviceId: THIS_DEVICE,
|
||||
trackId: null,
|
||||
vibeSessionId: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
position: 0,
|
||||
@@ -58,7 +66,11 @@ async function mountOwning() {
|
||||
describe('usePlaybackSync', () => {
|
||||
beforeEach(() => {
|
||||
emit = null;
|
||||
usePlaybackStore.setState({ position: 0, isPlaying: false, queue: [], currentTrack: null });
|
||||
useVibeStore.getState().reset();
|
||||
usePlaybackStore.setState({
|
||||
position: 0, isPlaying: false, queue: [], currentTrack: null, audioElsewhere: false,
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the reported position when it first gains the session', async () => {
|
||||
@@ -95,6 +107,93 @@ describe('usePlaybackSync', () => {
|
||||
expect(view.result.current.hasRemoteOwner).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the remote play state rather than its own while it watches', async () => {
|
||||
const view = await mountOwning();
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, isPlaying: true, version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(view.result.current.remotePlaying).toBe(true);
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlaybackStore.getState().audioElsewhere).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the Vibe session it is driving so the next device can take it', async () => {
|
||||
await mountOwning();
|
||||
act(() => {
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(playbackSyncService.reportState).toHaveBeenCalledWith(
|
||||
THIS_DEVICE,
|
||||
expect.objectContaining({ vibeSessionId: 'session-a' }),
|
||||
));
|
||||
});
|
||||
|
||||
it('stops driving the Vibe when the audio moves away, and adopts it back', async () => {
|
||||
const view = await mountOwning();
|
||||
act(() => {
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler(() => undefined);
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: 'session-a', version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
// One driver at a time, and it is whichever device holds the audio.
|
||||
expect(releaseVibeDriving).toHaveBeenCalled();
|
||||
expect(adoptVibeSession).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: 'session-a', version: 4 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(view.result.current.isOwner).toBe(true);
|
||||
expect(usePlaybackStore.getState().audioElsewhere).toBe(false);
|
||||
expect(adoptVibeSession).toHaveBeenCalledWith('session-a');
|
||||
});
|
||||
|
||||
it('lets go of its own Vibe when it takes over playback that is not one', async () => {
|
||||
await mountOwning();
|
||||
act(() => {
|
||||
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
|
||||
});
|
||||
|
||||
// The other device played an album, and this one is taking that over.
|
||||
await act(async () => {
|
||||
emit!({
|
||||
type: 'state',
|
||||
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: null, version: 3 }),
|
||||
devices: [],
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
emit!({ type: 'state', state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: null, version: 4 }), devices: [] });
|
||||
});
|
||||
|
||||
expect(adoptVibeSession).not.toHaveBeenCalled();
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps playing and reclaims the session when a dropped stream leaves it unowned', async () => {
|
||||
await mountOwning();
|
||||
act(() => usePlaybackStore.setState({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import {
|
||||
PlaybackCommand,
|
||||
PlaybackDevice,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
storedDeviceId,
|
||||
} from '../services/playbackSync';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { adoptVibeSession, releaseVibeDriving } from '../services/vibeSession';
|
||||
import type { Track } from '../types';
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,12 @@ export interface PlaybackSyncApi {
|
||||
isOwner: boolean;
|
||||
/** True once another device holds the audio, so controls become remote controls. */
|
||||
hasRemoteOwner: boolean;
|
||||
/**
|
||||
* Whether the device holding the audio is playing. The local `isPlaying` says
|
||||
* nothing about it — a watching device is always paused — so controls that
|
||||
* draw a play/pause state read this one while `hasRemoteOwner` is true.
|
||||
*/
|
||||
remotePlaying: boolean;
|
||||
transferTo: (deviceId: string) => Promise<void>;
|
||||
sendCommand: (command: PlaybackCommand) => Promise<void>;
|
||||
}
|
||||
@@ -41,6 +49,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
|
||||
const [devices, setDevices] = useState<PlaybackDevice[]>([]);
|
||||
const [ownerId, setOwnerId] = useState<string | null>(null);
|
||||
const [remotePlaying, setRemotePlaying] = useState(false);
|
||||
|
||||
const deviceIdRef = useRef<string | null>(deviceId);
|
||||
const ownerIdRef = useRef<string | null>(null);
|
||||
@@ -57,9 +66,13 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
const id = deviceIdRef.current;
|
||||
if (!id) return;
|
||||
const store = usePlaybackStore.getState();
|
||||
const vibe = useVibeStore.getState();
|
||||
try {
|
||||
await playbackSyncService.reportState(id, {
|
||||
trackId: store.currentTrack?.id ?? null,
|
||||
// Reported by the device driving the Vibe, and reported as null by a
|
||||
// device playing anything else, so the next owner knows which it is.
|
||||
vibeSessionId: store.queueOwner === 'vibe' ? vibe.activeSessionId : null,
|
||||
queue: store.queue,
|
||||
queueIndex: store.currentIndex,
|
||||
position: store.position,
|
||||
@@ -71,6 +84,22 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Take the queue off the wire without ending a Vibe this device is running.
|
||||
* `setQueue` is an ownership handoff: it drops the advance handler, which is
|
||||
* the thing that asks the server for the next Vibe track. A snapshot from
|
||||
* another device used to go through it, so mirroring the desktop — or taking
|
||||
* the audio back afterwards — turned a live session into a static list of the
|
||||
* hundred tracks that happened to be synced.
|
||||
*/
|
||||
const adoptQueue = useCallback((queue: Track[]) => {
|
||||
if (queue.length === 0) return;
|
||||
const store = usePlaybackStore.getState();
|
||||
const runningVibe = store.queueOwner === 'vibe' && useVibeStore.getState().activeSessionId !== null;
|
||||
if (runningVibe) store.setVibeQueue(queue);
|
||||
else store.setQueue(queue);
|
||||
}, []);
|
||||
|
||||
const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
|
||||
if (state.version <= lastVersion.current) return;
|
||||
lastVersion.current = state.version;
|
||||
@@ -81,6 +110,13 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current;
|
||||
setOwnerId(state.deviceId);
|
||||
|
||||
// What the controls draw, and whether the engine should load anything: both
|
||||
// follow from where the audio is, so they are set before any of the writes
|
||||
// below and on every snapshot, including the ones the owner ignores.
|
||||
const elsewhere = state.deviceId !== null && !iOwnIt;
|
||||
setRemotePlaying(elsewhere ? state.isPlaying : false);
|
||||
store.setAudioElsewhere(elsewhere);
|
||||
|
||||
// The server publishes a snapshot for anything that touches the session,
|
||||
// including another device merely registering on page load. For the device
|
||||
// already holding the audio those snapshots carry nothing new: the position
|
||||
@@ -103,9 +139,11 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
applyingRemote.current = true;
|
||||
try {
|
||||
if (!iOwnIt) {
|
||||
// Another device holds the audio. Show what it plays, make no sound.
|
||||
// Another device holds the audio, and with it the Vibe. Stop driving the
|
||||
// session from here, then show what it plays and make no sound.
|
||||
if (store.isPlaying) store.pause();
|
||||
if (state.queue.length > 0) store.setQueue(state.queue);
|
||||
if (store.vibeAdvanceHandler) releaseVibeDriving();
|
||||
adoptQueue(state.queue);
|
||||
const shown = state.queue[state.queueIndex] ?? store.currentTrack;
|
||||
if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
|
||||
store.setPosition(state.position);
|
||||
@@ -114,7 +152,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
|
||||
// We just took the session over: rebuild the queue, land on the right
|
||||
// track, and resume from where the previous device actually was.
|
||||
if (state.queue.length > 0) store.setQueue(state.queue);
|
||||
adoptQueue(state.queue);
|
||||
let track: Track | null = state.queue[state.queueIndex] ?? null;
|
||||
if (!track && state.trackId && state.trackId !== store.currentTrack?.id) {
|
||||
track = await trackService.getTrack(state.trackId).catch(() => null);
|
||||
@@ -126,7 +164,20 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
} finally {
|
||||
applyingRemote.current = false;
|
||||
}
|
||||
}, [reportNow]);
|
||||
|
||||
// Only a device that just took the audio reaches this: every other case
|
||||
// returned above. Vibe control travels with the audio, so pick up the
|
||||
// session the previous owner was driving — or let go of the one this device
|
||||
// still holds, because whatever is playing now is not it.
|
||||
if (state.vibeSessionId) {
|
||||
// Adoption rewrites the unplayed queue from the durable plan, and the
|
||||
// others are showing this device's queue, so tell them.
|
||||
if (await adoptVibeSession(state.vibeSessionId).catch(() => false)) void reportNow();
|
||||
} else if (useVibeStore.getState().activeSessionId) {
|
||||
releaseVibeDriving();
|
||||
useVibeStore.getState().reset();
|
||||
}
|
||||
}, [adoptQueue, reportNow]);
|
||||
|
||||
const applyCommand = useCallback(async (command: PlaybackCommand) => {
|
||||
const store = usePlaybackStore.getState();
|
||||
@@ -186,6 +237,9 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
cancelled = true;
|
||||
clearTimeout(retry);
|
||||
closeStream?.();
|
||||
// Nothing is watching the session any more, so nothing knows where the
|
||||
// audio is. Leaving the flag set would keep the engine silent for good.
|
||||
usePlaybackStore.getState().setAudioElsewhere(false);
|
||||
};
|
||||
}, [applySnapshot, applyCommand]);
|
||||
|
||||
@@ -256,6 +310,7 @@ export function usePlaybackSync(): PlaybackSyncApi {
|
||||
devices,
|
||||
isOwner: ownerId !== null && ownerId === deviceId,
|
||||
hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
|
||||
remotePlaying,
|
||||
transferTo,
|
||||
sendCommand,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PlaybackSyncContext } from '../components/PlaybackSyncProvider';
|
||||
import type { PlaybackSyncApi } from './usePlaybackSync';
|
||||
import type { PlaybackCommand } from '../services/playbackSync';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useTransport } from './useTransport';
|
||||
|
||||
@@ -12,6 +13,7 @@ function wrapperFor(api: Partial<PlaybackSyncApi>) {
|
||||
devices: [],
|
||||
isOwner: false,
|
||||
hasRemoteOwner: false,
|
||||
remotePlaying: false,
|
||||
transferTo: async () => undefined,
|
||||
sendCommand: async () => undefined,
|
||||
...api,
|
||||
@@ -36,12 +38,16 @@ describe('useTransport', () => {
|
||||
});
|
||||
|
||||
it('forwards the press when another device holds the audio', () => {
|
||||
usePlaybackStore.setState({ isPlaying: true });
|
||||
const sendCommand = vi.fn();
|
||||
// A watching device keeps its own audio paused, so what the button does
|
||||
// follows the remote state and never the local one.
|
||||
usePlaybackStore.setState({ isPlaying: false });
|
||||
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
|
||||
const { result } = renderHook(() => useTransport(), {
|
||||
wrapper: wrapperFor({ hasRemoteOwner: true, sendCommand }),
|
||||
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: true, sendCommand }),
|
||||
});
|
||||
|
||||
expect(result.current.playing).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.toggle();
|
||||
result.current.next();
|
||||
@@ -54,7 +60,19 @@ describe('useTransport', () => {
|
||||
{ type: 'seek', position: 30 },
|
||||
]);
|
||||
// The remote device is the one that stops; this one never started.
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(true);
|
||||
expect(usePlaybackStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('asks a paused remote device to play, whatever this device was doing', () => {
|
||||
usePlaybackStore.setState({ isPlaying: true });
|
||||
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
|
||||
const { result } = renderHook(() => useTransport(), {
|
||||
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: false, sendCommand }),
|
||||
});
|
||||
|
||||
act(() => result.current.toggle());
|
||||
|
||||
expect(sendCommand).toHaveBeenCalledWith({ type: 'play' });
|
||||
});
|
||||
|
||||
it('plays here when there is no sync session at all', () => {
|
||||
|
||||
@@ -19,30 +19,46 @@ export interface Transport {
|
||||
seek: (seconds: number) => void;
|
||||
/** True when the presses are being forwarded rather than played here. */
|
||||
remote: boolean;
|
||||
/**
|
||||
* Whether the audio is playing, wherever it is. A watching device holds its
|
||||
* own store paused, so a control that reads `isPlaying` from the store draws a
|
||||
* Play button while the desktop plays — and then sends `play` when the
|
||||
* listener presses it, which is why pausing from a phone never worked. Every
|
||||
* control reads this instead.
|
||||
*/
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export function useTransport(): Transport {
|
||||
const sync = useOptionalPlaybackSync();
|
||||
const hasRemoteOwner = sync?.hasRemoteOwner ?? false;
|
||||
const remotePlaying = sync?.remotePlaying ?? false;
|
||||
const sendCommand = sync?.sendCommand;
|
||||
const localPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const playing = hasRemoteOwner ? remotePlaying : localPlaying;
|
||||
|
||||
return useMemo(() => {
|
||||
const local = () => usePlaybackStore.getState();
|
||||
const away = hasRemoteOwner && sendCommand !== undefined;
|
||||
// The owning device may hold the session without listening on it — a killed
|
||||
// tab keeps it until the sweep. The backend answers that with a 409, and a
|
||||
// rejected press is not worth an unhandled rejection.
|
||||
const send = (command: Parameters<NonNullable<typeof sendCommand>>[0]) =>
|
||||
void Promise.resolve(sendCommand!(command)).catch(() => undefined);
|
||||
return {
|
||||
play: () => (away ? void sendCommand!({ type: 'play' }) : local().play()),
|
||||
pause: () => (away ? void sendCommand!({ type: 'pause' }) : local().pause()),
|
||||
play: () => (away ? send({ type: 'play' }) : local().play()),
|
||||
pause: () => (away ? send({ type: 'pause' }) : local().pause()),
|
||||
toggle: () => {
|
||||
const playing = local().isPlaying;
|
||||
if (away) void sendCommand!({ type: playing ? 'pause' : 'play' });
|
||||
if (away) send({ type: playing ? 'pause' : 'play' });
|
||||
else if (playing) local().pause();
|
||||
else local().play();
|
||||
},
|
||||
next: () => (away ? void sendCommand!({ type: 'next' }) : local().next()),
|
||||
prev: () => (away ? void sendCommand!({ type: 'prev' }) : local().prev()),
|
||||
next: () => (away ? send({ type: 'next' }) : local().next()),
|
||||
prev: () => (away ? send({ type: 'prev' }) : local().prev()),
|
||||
seek: (seconds: number) =>
|
||||
away ? void sendCommand!({ type: 'seek', position: seconds }) : local().setPosition(seconds),
|
||||
away ? send({ type: 'seek', position: seconds }) : local().setPosition(seconds),
|
||||
remote: away,
|
||||
playing,
|
||||
};
|
||||
}, [hasRemoteOwner, sendCommand]);
|
||||
}, [hasRemoteOwner, playing, sendCommand]);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PlaybackDevice {
|
||||
export interface PlaybackSnapshot {
|
||||
deviceId: string | null;
|
||||
trackId: string | null;
|
||||
/** The Vibe session the owning device is driving, if it is driving one. */
|
||||
vibeSessionId: string | null;
|
||||
queue: Track[];
|
||||
queueIndex: number;
|
||||
position: number;
|
||||
@@ -40,9 +42,19 @@ export type PlaybackSyncEvent =
|
||||
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
|
||||
| { type: 'command'; deviceId: string; command: PlaybackCommand };
|
||||
|
||||
/**
|
||||
* The id this tab registered under, or the one the browser last used.
|
||||
*
|
||||
* Two levels, because a device is really a tab and not a browser: the audio
|
||||
* element, the queue and the command handling all live in one page. Two tabs
|
||||
* sharing a single id are one device that runs every command twice, so each tab
|
||||
* keeps its own id in `sessionStorage` — which survives its reloads and nothing
|
||||
* else. The `localStorage` copy is only a seed for a tab that has no id yet, and
|
||||
* the server refuses to hand it back while another tab's stream is holding it.
|
||||
*/
|
||||
export function storedDeviceId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(DEVICE_ID_KEY);
|
||||
return sessionStorage.getItem(DEVICE_ID_KEY) ?? localStorage.getItem(DEVICE_ID_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -50,6 +62,7 @@ export function storedDeviceId(): string | null {
|
||||
|
||||
function rememberDeviceId(id: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(DEVICE_ID_KEY, id);
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
} catch {
|
||||
// Private browsing: the device still works, it just re-registers next load.
|
||||
@@ -95,6 +108,7 @@ export const playbackSyncService = {
|
||||
/** Report what this device is playing. Rejected with 409 once it is not the owner. */
|
||||
async reportState(deviceId: string, patch: {
|
||||
trackId?: string | null;
|
||||
vibeSessionId?: string | null;
|
||||
queue?: Track[];
|
||||
queueIndex?: number;
|
||||
position?: number;
|
||||
@@ -162,14 +176,27 @@ export const playbackSyncService = {
|
||||
if (!source || source.readyState === EventSource.CLOSED) open();
|
||||
};
|
||||
|
||||
// React never unmounts on a page leaving, so the stream would stay open
|
||||
// until the server noticed the socket die. Closing it here frees the device
|
||||
// id straight away, which is what lets a reload register as the same device
|
||||
// rather than being told it is a second tab. A page that comes back from the
|
||||
// history cache reopens through `reopenIfDead`.
|
||||
const closeForNow = () => {
|
||||
clearTimeout(timer);
|
||||
source?.close();
|
||||
source = null;
|
||||
};
|
||||
|
||||
open();
|
||||
window.addEventListener('online', reopenIfDead);
|
||||
window.addEventListener('pagehide', closeForNow);
|
||||
document.addEventListener('visibilitychange', reopenIfDead);
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('online', reopenIfDead);
|
||||
window.removeEventListener('pagehide', closeForNow);
|
||||
document.removeEventListener('visibilitychange', reopenIfDead);
|
||||
source?.close();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface VibePlanItem {
|
||||
|
||||
export interface DurableVibeSessionResponse {
|
||||
sessionId: string;
|
||||
/** The durable session row. Present on start, resume and plan reads. */
|
||||
session?: { id: string; seed_track_id: string | null };
|
||||
planVersion: number | null;
|
||||
now: VibePlanItem | null;
|
||||
preview: VibePlanItem[];
|
||||
@@ -71,6 +73,16 @@ export const vibeService = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pick up a session that is already running, on a device that did not start
|
||||
* it. Resuming is how Vibe control follows the audio between devices; it also
|
||||
* revives a session the reaper had paused.
|
||||
*/
|
||||
async resume(sessionId: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { resumeSessionId: sessionId });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
|
||||
params: version === undefined ? undefined : { version },
|
||||
|
||||
@@ -4,13 +4,21 @@ import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
|
||||
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
|
||||
const { start, resume, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
|
||||
start: vi.fn(), resume: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
|
||||
}));
|
||||
vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
|
||||
|
||||
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
|
||||
import {
|
||||
adoptVibeSession,
|
||||
advancePastUnplayableVibeTrack,
|
||||
advanceVibe,
|
||||
endVibeSession,
|
||||
releaseVibeDriving,
|
||||
reportVibeEvent,
|
||||
startVibeSession,
|
||||
} from './vibeSession';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
|
||||
@@ -23,7 +31,7 @@ const item = (track_id: string, committed = false, ordinal = 0) => ({
|
||||
score: 1, score_breakdown: {}, explanation: [], committed,
|
||||
});
|
||||
|
||||
const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({
|
||||
const response = (planVersion: number, now: ReturnType<typeof item> | null = item('one', true), preview = [item('two')]) => ({
|
||||
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
|
||||
});
|
||||
|
||||
@@ -349,6 +357,70 @@ describe('durable Vibe session client', () => {
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
});
|
||||
|
||||
it('adopts a session running on another device and drives it from here', async () => {
|
||||
// What a device that has just been handed the audio starts from: the queue
|
||||
// and playing track came off the playback snapshot, the session did not.
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('one'), queue: [track('one')], currentIndex: 0, isPlaying: true,
|
||||
});
|
||||
resume.mockResolvedValue({ ...response(3, null, [item('two'), item('three')]), session: { id: 'session-a', seed_track_id: 'one' } });
|
||||
|
||||
await expect(adoptVibeSession('session-a')).resolves.toBe(true);
|
||||
|
||||
expect(resume).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState()).toMatchObject({
|
||||
activeSessionId: 'session-a', seedTrackId: 'one', planVersion: 3, buffer: [track('two'), track('three')],
|
||||
});
|
||||
// No cursor is recoverable for the track already playing; the next advance sets one.
|
||||
expect(useVibeStore.getState().currentPlanItem).toBeNull();
|
||||
expect(usePlaybackStore.getState().queueOwner).toBe('vibe');
|
||||
expect(usePlaybackStore.getState().vibeAdvanceHandler).not.toBeNull();
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
|
||||
// The track that was playing keeps playing: adoption replaces the future only.
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
});
|
||||
|
||||
it('keeps playing the ordinary queue when the session it was told about is gone', async () => {
|
||||
usePlaybackStore.setState({ currentTrack: track('one'), queue: [track('one')], currentIndex: 0 });
|
||||
resume.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
|
||||
await expect(adoptVibeSession('session-a')).resolves.toBe(false);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null });
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops driving a session without ending it when the audio moves away', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('one'));
|
||||
|
||||
releaseVibeDriving();
|
||||
|
||||
expect(usePlaybackStore.getState().vibeAdvanceHandler).toBeNull();
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
|
||||
expect(end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an unplayable stream as a skip while an adopted session has no cursor', async () => {
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('one'), queue: [track('one')], currentIndex: 0,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
});
|
||||
resume.mockResolvedValue(response(3, null, [item('two')]));
|
||||
await adoptVibeSession('session-a');
|
||||
event.mockResolvedValue(response(4, null, [item('two')]));
|
||||
next.mockResolvedValue(response(4, item('two', true), []));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
expect(advancePastUnplayable).not.toHaveBeenCalled();
|
||||
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
});
|
||||
|
||||
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
@@ -482,6 +482,16 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
*/
|
||||
export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
||||
if (advanceInFlight) return advanceInFlight;
|
||||
// A session adopted from another device has no cursor until its first advance.
|
||||
// Without one there is no durable item to step past, so report the failure as
|
||||
// an ordinary skip rather than stalling on a track that will not play.
|
||||
const adopted = useVibeStore.getState();
|
||||
if (
|
||||
adopted.activeSessionId && !adopted.currentPlanItem && seedPendingTrackId !== trackId
|
||||
&& usePlaybackStore.getState().currentTrack?.id === trackId
|
||||
) {
|
||||
return advanceVibe('skipped');
|
||||
}
|
||||
advanceInFlight = serializeMaterial(async () => {
|
||||
const vibe = useVibeStore.getState();
|
||||
const playback = usePlaybackStore.getState();
|
||||
@@ -572,6 +582,70 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over a session that is already running, on the device that has just been
|
||||
* given the audio.
|
||||
*
|
||||
* A Vibe has two halves: the durable plan on the server, and the controller here
|
||||
* that reports outcomes and asks for the next track. Only one device may hold
|
||||
* the controller, and the right one is whichever holds the audio — otherwise the
|
||||
* device that started the session keeps replanning for a player it cannot hear,
|
||||
* or, as it did before this existed, nobody replans at all and the Vibe quietly
|
||||
* becomes a fixed list of whatever tracks were synced.
|
||||
*
|
||||
* The queue and playing track are already in place from the playback snapshot;
|
||||
* this restores the session around them.
|
||||
*/
|
||||
export async function adoptVibeSession(sessionId: string): Promise<boolean> {
|
||||
return serializeMaterial(async () => {
|
||||
const playback = usePlaybackStore.getState();
|
||||
const alreadyDriving = useVibeStore.getState().activeSessionId === sessionId
|
||||
&& playback.queueOwner === 'vibe'
|
||||
&& playback.vibeAdvanceHandler !== null;
|
||||
if (alreadyDriving) return true;
|
||||
|
||||
let response: DurableVibeSessionResponse;
|
||||
try {
|
||||
response = await vibeService.resume(sessionId);
|
||||
} catch (error) {
|
||||
// Ended, replaced or simply gone: there is nothing to drive, and the
|
||||
// ordinary queue this device received is the honest thing to keep playing.
|
||||
if (isSessionTerminalError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
if (!response.planVersion) return false;
|
||||
|
||||
const preview = await hydratePreview(response.preview);
|
||||
const vibe = useVibeStore.getState();
|
||||
// A different session, or a stale local revision of this one: drop it before
|
||||
// admitting the revision the server just reported.
|
||||
if (vibe.activeSessionId !== sessionId) vibe.reset();
|
||||
vibe.setActiveSession({ sessionId, seedTrackId: response.session?.seed_track_id ?? null });
|
||||
vibe.setPlan(response.planVersion, preview);
|
||||
vibe.setProfile(response.state);
|
||||
// The cursor for the track already playing was committed in a revision that
|
||||
// a later replan superseded, so it is not in this response and cannot be
|
||||
// reconstructed. The first advance sets it; until then a stream that fails
|
||||
// is reported as an ordinary skip.
|
||||
vibe.setCurrentPlanItem(null);
|
||||
|
||||
// Ownership first: replaceUnplayedQueue and every session guard read it.
|
||||
usePlaybackStore.getState().setVibeQueue(usePlaybackStore.getState().queue);
|
||||
installVibeAdvanceHandler();
|
||||
replaceUnplayedQueue(preview);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up driving the session without ending it, because the audio has moved to
|
||||
* another device. The session stays on record here so the local Vibe view still
|
||||
* has something to show, and taking the audio back adopts it again.
|
||||
*/
|
||||
export function releaseVibeDriving(): void {
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler(null);
|
||||
}
|
||||
|
||||
export async function endVibeSession(): Promise<void> {
|
||||
return serializeMaterial(async () => {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
|
||||
@@ -41,6 +41,12 @@ interface PlaybackState {
|
||||
vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null;
|
||||
/** Vibe must opt in explicitly; ordinary browsing always owns itself. */
|
||||
queueOwner: PlaybackOwner;
|
||||
/**
|
||||
* True while another device holds the audio and this one is only showing what
|
||||
* it plays. The engine loads no stream in that state, so a phone watching the
|
||||
* desktop stops pulling megabytes of audio it will never play.
|
||||
*/
|
||||
audioElsewhere: boolean;
|
||||
|
||||
setQueue: (queue: Track[]) => void;
|
||||
/** Vibe-only queue replacement. Do not use for library browsing. */
|
||||
@@ -60,6 +66,7 @@ interface PlaybackState {
|
||||
setPrefetchNext: (prefetchNext: boolean) => void;
|
||||
setCrossfadeMs: (crossfadeMs: number) => void;
|
||||
setCurrentTrack: (track: Track | null) => void;
|
||||
setAudioElsewhere: (audioElsewhere: boolean) => void;
|
||||
toggleShuffle: () => void;
|
||||
cycleRepeat: () => void;
|
||||
}
|
||||
@@ -106,6 +113,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
shufflePlayed: new Set<string>(),
|
||||
vibeAdvanceHandler: null,
|
||||
queueOwner: 'ordinary',
|
||||
audioElsewhere: false,
|
||||
|
||||
setQueue: (queue) =>
|
||||
set((state) => ({
|
||||
@@ -273,6 +281,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1,
|
||||
})),
|
||||
|
||||
setAudioElsewhere: (audioElsewhere) => set({ audioElsewhere }),
|
||||
|
||||
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
|
||||
cycleRepeat: () =>
|
||||
set((state) => {
|
||||
|
||||
Reference in New Issue
Block a user