Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/src/assets/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dj-amber</title>
<title>DJ-AMBER</title>
</head>
<body>
<div id="root"></div>
Expand Down
3 changes: 3 additions & 0 deletions src/audio/AudioEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export class AudioEngine extends EventEmitter<AudioEngineEvents> {
() => 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', {
Expand Down
6 changes: 3 additions & 3 deletions src/audio/TransportController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/audio/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
9 changes: 7 additions & 2 deletions src/components/NowPlaying.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
};

Expand Down
33 changes: 31 additions & 2 deletions src/components/SetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -82,6 +110,7 @@ export default function SetList({

return () => {
unsubSong();
unsubSeeked();
unsubState();
};
// engine is a stable singleton - this runs exactly once on mount.
Expand Down
Loading