diff --git a/docs/player-mode.md b/docs/player-mode.md index 5fa50e0..1df5edc 100644 --- a/docs/player-mode.md +++ b/docs/player-mode.md @@ -11,6 +11,9 @@ The code is in `web/src/lib/speaker.ts`, with its UI in `web/src/components/room - **Unlocking:** browsers only allow audio after a tap. Tapping **Play on this phone** plays a tenth of a second of silence on both elements before anything async happens. That keeps them both allowed to play later. If the browser still blocks playback, the panel shows **Tap to start the audio**. - **Format:** the stream URL carries `accept=` with the types this browser says it can play (`canPlayType`). The server transcodes anything else. - **Reports:** `playing` when audio starts, `progress` every 5s while playing, `paused` when something outside the app pauses it (a Bluetooth disconnect, a phone call), `ended`, and `error` after two retries. +- **Stalls:** a stream can stop arriving without an error, so the element says it's playing while nothing is heard. If the position hasn't moved for 10s while the room plays, the stream is loaded again where it was. After two of those in a row, the speaker reports an `error`, and the server tries the song elsewhere or skips it. +- **On the wrong song:** a device can end up on another song than the room's with no new revision coming to fix it, for example after a stream ended early and it started the preloaded next song. When the room's state says another song is playing, it loads that one at the room's position. Starting the next song in the last 10s of the room's is the gapless handoff, and is left alone. +- **One tab plays:** tabs of one browser share its device ID, so the server can't tell them apart. The newest tab to play tells the others, over a `BroadcastChannel`, to go quiet. - **Network blips:** a failed stream is reloaded from the same position, with backoff. The room socket reconnects on its own and catches up. If the server forgets the speaker (after a restart), the speaker claims the room again. If another device takes over, this one stops and says so. - **Media Session:** sets the lock-screen title, artist, album, and artwork, plus the position. Play, pause, next, previous (restarts the song, or in its first few seconds goes back to the one before), and seek are wired to the room. So the speaker's and headphones' buttons control the room, subject to the room's permissions. - **Screen Wake Lock:** **Keep the screen on** is on by default and remembered per device. The lock is requested again whenever the page becomes visible, because browsers drop it when the page is hidden. diff --git a/server/internal/playback/playback.go b/server/internal/playback/playback.go index b40288b..3c53450 100644 --- a/server/internal/playback/playback.go +++ b/server/internal/playback/playback.go @@ -412,6 +412,7 @@ func (e *Engine) ClaimAndPlay(ctx context.Context, roomID, userID, deviceID, nam return rooms.NowPlaying{}, err } r.np.Player = &rooms.Player{DeviceID: deviceID, UserID: userID, Name: name, LastSeen: now} + slog.Info("playback: speaker claimed", "room", r.id, "device", deviceID, "name", name, "user", userID, "again", same, "item", idOf(r.np.Item), "state", r.np.State) if !same { // A new speaker has to load the song. r.np.Position, r.np.At = r.position(now), now @@ -494,6 +495,9 @@ func (e *Engine) release(ctx context.Context, r *room) error { if err := e.db.SetRoomPlayer(ctx, store.SetRoomPlayerParams{ID: r.id}); err != nil { return err } + if p := r.np.Player; p != nil { + slog.Info("playback: speaker released", "room", r.id, "device", p.DeviceID, "name", p.Name, "item", idOf(r.np.Item)) + } r.np.Player = nil if r.np.State == StatePlaying || r.np.State == StateLoading { if err := e.pause(ctx, r); err != nil { @@ -592,6 +596,7 @@ func (e *Engine) Command(ctx context.Context, roomID, userID string, c Command) } else if !store.IsNotFound(err) { return rooms.NowPlaying{}, err } + slog.Info("playback: command", "room", r.id, "user", userID, "action", c.Action, "item", c.ItemID, "playing", idOf(r.np.Item), "state", r.np.State) now := e.cfg.Now() switch c.Action { case ActionPlay: @@ -999,7 +1004,14 @@ func (e *Engine) Report(ctx context.Context, roomID, userID string, rep Report) } now := e.cfg.Now() p.LastSeen = now - if r.np.Item == nil || r.np.Item.ID != rep.ItemID || r.np.Driver != string(provider.PlaybackStream) { + stale := r.np.Item == nil || r.np.Item.ID != rep.ItemID || r.np.Driver != string(provider.PlaybackStream) + // Progress is every few seconds; only a stale one says something's off + // (the speaker is on another song than the room). + if rep.Event != EventProgress || stale { + slog.Info("playback: report", "room", r.id, "device", rep.DeviceID, "event", rep.Event, "item", rep.ItemID, + "position", rep.Position.Round(time.Millisecond), "playing", idOf(r.np.Item), "state", r.np.State, "stale", stale, "err", rep.Error) + } + if stale { return e.view(r), nil } r.heard = true @@ -1065,6 +1077,7 @@ func (e *Engine) nextItem(ctx context.Context, r *room, reason, want string) err return err } want = "" + slog.Info("playback: next song", "room", r.id, "reason", reason, "item", idOf(item)) now := e.cfg.Now() r.votes = nil // A request to go back is about the song that was playing. diff --git a/web/src/lib/speaker.test.ts b/web/src/lib/speaker.test.ts index ba84d86..27fbe70 100644 --- a/web/src/lib/speaker.test.ts +++ b/web/src/lib/speaker.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import type { Playback } from './playback' import { newer } from './playback' -import { acceptedTypes, canSeek, deviceId, streamUrl, targetPosition } from './speaker' +import type { QueueItem } from './playback' +import { acceptedTypes, canSeek, deviceId, offSong, streamUrl, targetPosition } from './speaker' const pb = (p: Partial): Playback => ({ roomId: 'r', @@ -26,6 +27,31 @@ describe('targetPosition', () => { }) }) +describe('offSong', () => { + const at = Date.parse('2026-10-05T12:00:00Z') + const song = (id: string) => ({ id, track: { durationMs: 200_000 } }) as QueueItem + const room = (positionMs: number) => pb({ item: song('helena'), next: song('feel-it-still'), positionMs, driver: 'stream' }) + it('is fine on the room song', () => { + expect(offSong(room(60_000), 'helena', at)).toBe(false) + }) + it('catches being on the next song with most of this one to go', () => { + expect(offSong(room(60_000), 'feel-it-still', at)).toBe(true) + }) + it('allows starting the next song in the last moments (gapless handoff)', () => { + expect(offSong(room(195_000), 'feel-it-still', at)).toBe(false) + expect(offSong(room(205_000), 'feel-it-still', at)).toBe(false) + }) + it('catches any other song, or none', () => { + expect(offSong(room(195_000), 'roxanne', at)).toBe(true) + expect(offSong(room(60_000), undefined, at)).toBe(true) + }) + it('leaves paused, idle and remote rooms alone', () => { + expect(offSong({ ...room(60_000), state: 'paused' }, 'feel-it-still', at)).toBe(false) + expect(offSong({ ...room(60_000), item: undefined, state: 'idle' }, 'feel-it-still', at)).toBe(false) + expect(offSong({ ...room(60_000), driver: 'remote' }, 'feel-it-still', at)).toBe(false) + }) +}) + describe('acceptedTypes', () => { it('keeps only what the browser can play', () => { expect(acceptedTypes((t) => (t === 'audio/mpeg' || t === 'audio/flac' ? 'maybe' : ''))).toEqual(['audio/mpeg', 'audio/flac']) diff --git a/web/src/lib/speaker.ts b/web/src/lib/speaker.ts index d93cbfa..284fe2b 100644 --- a/web/src/lib/speaker.ts +++ b/web/src/lib/speaker.ts @@ -60,6 +60,14 @@ const RETRIES = 2 const HANDOFF = 5_000 // How long one report may take before the next one goes anyway. const REPORT_TIMEOUT = 10_000 +// A stream that says it's playing but hasn't moved for this long has +// stalled, and is loaded again where it was. +const STALL = 10_000 +const STALL_CHECK = 2_000 +// Starting the next song this close to the end of the room's is a gapless +// handoff the server hasn't caught up with yet, not being on the wrong song. +// Generous, because a file can run a few seconds off its listed length. +const HANDOFF_GRACE = 10_000 export const speakerState = createStore({ status: 'off', mode: 'speaker', keepAwake: readFlag(AWAKE_KEY, true) }) @@ -96,6 +104,18 @@ export function targetPosition(p: Playback, now = serverNow()) { return p.positionMs + Math.max(0, now - Date.parse(p.at)) } +/** + * Whether a device on songId is on another song than the room is playing. + * Being on the room's next song in its last moments is a gapless handoff, + * and fine; anywhere else (a stream that ended early, say) it's wrong, and + * no new revision may come along to fix it. + */ +export function offSong(p: Playback, songId: string | undefined, now = serverNow()) { + if (p.driver === 'remote' || !p.item || (p.state !== 'playing' && p.state !== 'loading') || songId === p.item.id) return false + if (songId !== undefined && songId === p.next?.id) return p.item.track.durationMs - targetPosition(p, now) > HANDOFF_GRACE + return true +} + /** * Whether a media element can seek to `seconds`. A transcoded stream can't * be seeked by bytes; browsers report it with no seekable range, or only @@ -152,6 +172,16 @@ class Speaker { // The room plays on; this device stays quiet until it's played again. private localPause = false private resyncTimer?: ReturnType + private stallTimer?: ReturnType + // Where the current song last moved, and when, for spotting a stall. + private moved = { at: 0, ms: -1 } + // Stalls in a row, and where in the song the last one was. + private stalls = 0 + private stalledMs = 0 + // Tabs of one browser share its device ID, so the server can't tell them + // apart: the newest to play tells the others to go quiet. + private readonly instance = crypto.randomUUID() + private tabs?: BroadcastChannel private stopping?: ReturnType // Reports go one at a time, in order: a song's "ended" must land before // the next song's "playing", or the server drops the "playing" and waits. @@ -280,7 +310,10 @@ class Speaker { } this.preload(np) this.mediaSession(np) - if (np.revision <= this.rev) { + // On another song than the room at the same revision (our stream ended + // early, say): nothing new is coming to fix it, so apply it again. + const wrongSong = np.revision === this.rev && offSong(np, this.curItem?.id) + if (np.revision <= this.rev && !wrongSong) { // Already applied. If the server is waiting for us to start a song // we started early (gapless handoff), say so now. if (np.state === 'loading' && np.item?.id === this.curItem?.id && !this.cur.paused) this.report('playing') @@ -353,6 +386,8 @@ class Speaker { el.play().catch(() => {}) } clearInterval(this.resyncTimer) + clearInterval(this.stallTimer) + this.stallTimer = setInterval(() => this.checkStall(), STALL_CHECK) this.roomId = roomId this.mode = mode this.name = name @@ -361,6 +396,12 @@ class Speaker { this.localPause = false this.canSpeak = false this.rev = -1 + // Both elements hold silence now, whatever they had: the song has to + // load again, and the next one preload again. + this.curItem = undefined + this.preItem = undefined + this.stalls = 0 + this.tabs?.postMessage({ playing: this.instance, device }) this.set({ status: 'waiting', mode, roomId }) this.mediaHandlers() void this.keepAwake(speakerState.get().keepAwake) @@ -381,6 +422,18 @@ class Speaker { this.els = [make(), make()] ;[this.cur, this.pre] = this.els window.addEventListener('online', () => this.active && this.cur.error && this.recover()) + if (typeof BroadcastChannel !== 'undefined') { + this.tabs = new BroadcastChannel('syncphony-speaker') + this.tabs.onmessage = (e: MessageEvent<{ playing?: string; device?: string }>) => { + if (!this.active || !e.data.playing || e.data.playing === this.instance) return + // Another tab of this browser plays now. As the same device, it + // carries on from us, so just go quiet; as another (a paired + // screen's), let the room go properly. + if (e.data.device === this.device) this.halt() + else void this.stop() + toast({ message: 'Playing in another tab now.' }) + } + } document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible' || !this.active) return void this.keepAwake(speakerState.get().keepAwake) @@ -404,6 +457,7 @@ class Speaker { private load(item: QueueItem, positionMs: number) { this.retries = 0 + this.stalls = 0 if (this.preItem === item.id) { this.expectPause() this.cur.pause() @@ -543,6 +597,7 @@ class Speaker { this.preItem = undefined this.curItem = item this.retries = 0 + this.stalls = 0 this.setSrc(this.cur, item) this.play() } @@ -573,9 +628,38 @@ class Speaker { if (playing && !this.localPause) this.play() } + /** + * A stream can stop arriving without an error: the element still says + * it's playing, but nothing moves and nothing is heard. Load it again + * where it was; after RETRIES of those in a row, a speaker reports it. + */ + private checkStall() { + const now = Date.now() + const ms = this.position() + const np = this.last + const watching = + !!this.curItem && !this.cur.paused && !this.localPause && np?.state === 'playing' && np.item?.id === this.curItem.id + if (!watching || ms !== this.moved.ms) { + this.moved = { at: now, ms } + // Played on well past the stall (not just reloaded to it): it was a blip. + if (ms > this.stalledMs + STALL) this.stalls = 0 + return + } + if (now - this.moved.at < STALL) return + this.moved = { at: now, ms } + this.stalledMs = ms + if (this.stalls++ < RETRIES) { + this.recover() + } else if (this.mode === 'speaker') { + this.report('error', this.curItem, 'the stream stalled') + } + } + private halt() { clearInterval(this.resyncTimer) this.resyncTimer = undefined + clearInterval(this.stallTimer) + this.stallTimer = undefined this.roomId = undefined this.curItem = undefined this.preItem = undefined