Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/player-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion server/internal/playback/playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 27 additions & 1 deletion web/src/lib/speaker.test.ts
Original file line number Diff line number Diff line change
@@ -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>): Playback => ({
roomId: 'r',
Expand All @@ -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'])
Expand Down
86 changes: 85 additions & 1 deletion web/src/lib/speaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpeakerState>({ status: 'off', mode: 'speaker', keepAwake: readFlag(AWAKE_KEY, true) })

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof setInterval>
private stallTimer?: ReturnType<typeof setInterval>
// 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<typeof setTimeout>
// 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.
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
Loading