diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts index baaf726..92676b2 100644 --- a/src/audio/AudioEngine.ts +++ b/src/audio/AudioEngine.ts @@ -65,6 +65,9 @@ export class AudioEngine extends EventEmitter { () => this.playlistManager.getTotalDuration(), (state) => this.emit('stateChange', { state }), (time) => { + // emit seeked first so listeners can update their tracking refs + // before the subsequent songChange fires + this.emit('seeked', { time }); const entry = this.playlist.getEntryAtTime(time); if (entry) this.emit('songChange', { diff --git a/src/audio/TransportController.ts b/src/audio/TransportController.ts index deed3e0..86a82c0 100644 --- a/src/audio/TransportController.ts +++ b/src/audio/TransportController.ts @@ -70,14 +70,14 @@ export class TransportController { const totalDuration = this.getDuration(); this.pausedAt = Math.max(0, Math.min(time, totalDuration)); + // Notify before restarting the scheduler to ensure seeked fires before songChange + this.onSeek(this.pausedAt); + if (this.state === 'playing') { this.scheduler.stopAll(); this.contextTimeAtPlay = this.ctx.currentTime; this.transportTimeAtPlay = this.pausedAt; this.scheduler.start(this.pausedAt, this.contextTimeAtPlay); - } else { - // paused/stopped: playhead moves but no audio, notify so song title updates - this.onSeek(this.pausedAt); } } diff --git a/src/audio/types.ts b/src/audio/types.ts index 29fc3d7..56591c7 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -66,6 +66,7 @@ export type AudioEngineEvents = { timelineChange: { entries: ScheduledEntry[] }; playlistChange: { entries: PlaylistEntry[] }; error: { message: string; error?: unknown }; + seeked: { time: Seconds }; }; export type { ID, Seconds, Gain, Fade, FadeType }; diff --git a/src/components/NowPlaying.tsx b/src/components/NowPlaying.tsx index 85ca7ac..203ef94 100644 --- a/src/components/NowPlaying.tsx +++ b/src/components/NowPlaying.tsx @@ -116,10 +116,15 @@ export default function NowPlaying({ libraryItems }: NowPlayingProps) { }; const handlePlayPause = () => { - if (isPlaying) { + // Read state directly from the engine, not React state. + // play() is async (awaits ctx.resume), so transportState can lag or miss errors, + // leaving isPlaying stale and causing no-op play() calls. getState() stays accurate. + if (engine.transport.getState() === 'playing') { engine.transport.pause(); } else { - void engine.transport.play(); + engine.transport.play().catch((err: unknown) => { + console.error('Playback failed to start:', err); + }); } }; diff --git a/src/components/SetList.tsx b/src/components/SetList.tsx index 90481b2..5cb44e4 100644 --- a/src/components/SetList.tsx +++ b/src/components/SetList.tsx @@ -54,8 +54,14 @@ export default function SetList({ currentEntryIdRef.current = engine.getCurrentEntry()?.entryId ?? null; const unsubSong = engine.on('songChange', ({ entryId }) => { - if (repeatModeRef.current === 'one' && currentEntryIdRef.current !== null) { - // Song changed - seek back to the start of the previous song. + // Only handle real track changes. songChange also fires on seek within the same track, + // so without this guard, Repeat One would override seeks by jumping back to absoluteStart. + if ( + repeatModeRef.current === 'one' && + currentEntryIdRef.current !== null && + entryId !== currentEntryIdRef.current + ) { + // A genuine automatic song transition occurred - seek back to the start of the previous song. const prev = engine.getTimeline().find((e) => e.entryId === currentEntryIdRef.current); if (prev) { engine.transport.seek(prev.absoluteStart); @@ -65,6 +71,17 @@ export default function SetList({ currentEntryIdRef.current = entryId; }); + // When the user seeks on the timeline, update currentEntryIdRef to the song at the new position + // to prevent Repeat One from interfering with intentional navigation + const unsubSeeked = engine.on('seeked', ({ time }) => { + const entry = engine + .getTimeline() + .find((e) => time >= e.absoluteStart && time < e.absoluteEnd); + if (entry) { + currentEntryIdRef.current = entry.entryId; + } + }); + const unsubState = engine.on('stateChange', ({ state }) => { if (state === 'playing') { wasPlayingRef.current = true; @@ -74,6 +91,17 @@ export default function SetList({ wasPlayingRef.current = false; engine.transport.seek(0); void engine.transport.play(); + } else if (repeatModeRef.current === 'one' && wasPlayingRef.current) { + // When the last song finishes, songChange doesn't fire, so Repeat One would silently fall through + // This seeks back to the start of the current song and replays it, similar to how songChange handles mid-playlist transitions + wasPlayingRef.current = false; + const entry = engine.getTimeline().find((e) => e.entryId === currentEntryIdRef.current); + if (entry) { + engine.transport.seek(entry.absoluteStart); + engine.transport.play().catch((err: unknown) => { + console.error('Repeat One: playback failed to restart:', err); + }); + } } else { wasPlayingRef.current = false; } @@ -82,6 +110,7 @@ export default function SetList({ return () => { unsubSong(); + unsubSeeked(); unsubState(); }; // engine is a stable singleton - this runs exactly once on mount.