The player¶
player/player.js exports one Player with one set of commands: play, enqueue, toggle, next, previous, seek, setVolume, toggleShuffle, cycleRepeat, move, remove and setOutput. It passes each command to whichever output is chosen, which is remembered in localStorage['nessie-output'].
flowchart TB
P["Player (player.js)"]
S["ServerOutput<br/>server-output.js"]
B["BrowserOutput<br/>browser-output.js"]
N["NativeOutput<br/>native-output.js (the app)"]
P --> S
P --> B
P --> N
N -- "NessiePlayer plugin" --> X["ExoPlayer (Android)<br/>AVQueuePlayer (iOS)"]
S -- "POST /v1/player/{action}<br/>GET /v1/player every 1 s" --> SRV["Server: mpv"]
B -- "audio.src = /v1/library/tracks/{id}/stream" --> A["#lt;audio#gt;"]
B -- "on ended: POST /tracks/{id}/played" --> SRV2["Server: play history"]
- ServerOutput sends
POST /v1/player/play,/queue,/nextand so on. Each command returns the new state. While it's the chosen output and the tab is visible, it keeps up withGET /v1/player, because another device may be controlling the same speakers. With live updates, it asks when told and counts the time on by itself; without them, it polls every second. A 503 means mpv isn't available; the UI shows one warning and keeps retrying quietly. - BrowserOutput keeps the queue in the page and plays through an
<audio>element. When a track ends, it reports the play to the server (POST /v1/library/tracks/{id}/played). It also feeds the lock screen and media keys through the Media Session API. - NativeOutput plays "This device" in the Android and iOS apps, on the app's own player (see The Android and iOS apps). It keeps a copy of the native queue and takes the same commands in the same state shape. Shuffle is worked out in the page and sent as a new order. Its
volumeisnull, because the phone's buttons control it, so the sliders hide (player.hasVolume).
The output you choose (outputId: server or browser) isn't always what plays it (engineId). engineFor() picks the native player for "This device" when the app's plugin answers with version 1 or later (NATIVE_VERSION). From version 2 (OFFLINE_VERSION) it stays native offline too, playing the app's own downloads: NativeOutput.tellConnection() calls setOffline on every connection change. With a version 1 app, offline falls back to BrowserOutput, and reselect() moves the queue between the two with adopt(queue, position, elapsed), paused at the same place:
- going offline, at once, because only the browser can reach the service worker's downloads;
- back online, once the browser isn't playing, so nothing gets cut off.
In the app, "This device" is the output until another is chosen.
Both outputs report state in the same shape, which is the server's PlayerStateOut. The bar, queue and Now Playing never need to check which output is active:
{ name, status: "stopped"|"playing"|"paused", queue: [trackId…], position, track: TrackOut|null,
elapsed, duration, volume, error, shuffle: bool, repeat: "off"|"all"|"one",
airplay: null|{ title, artist, artwork_url, status, client, … } }
player/display.js turns a state into what the controls show (title, subtitle, art, whether seeking is allowed), shared by the bar and the sheet. While AirPlay is playing through the server, AirPlay's metadata is shown instead and the controls are disabled.
Watch for this
Browsers only let audio.play() start inside the click that asked for it. That's why BrowserOutput.load() doesn't await before playing, and why wirePlayActions only awaits when the song list really is a promise. If you add an await between a click and play, playback on This device can silently stop working, especially on iOS.
Shuffle and repeat¶
Both outputs keep their own shuffle and repeat settings, in memory, and report them in the state. showModes() in display.js updates the buttons in the player bar and Now Playing. Shuffle is a toggle button (aria-pressed). Repeat has three settings, so its name says which one it's on: "Repeat: off", "Repeat: all" or "Repeat: this song".
- Shuffle puts only the songs after the current one in a random order. The ones already played stay where they are, so Previous still goes back through them. The order before shuffling is remembered (
_unshuffledon the server,unshuffledin the browser). Turning shuffle off brings that order back, and playback carries on from the current song wherever it lands. Songs added or removed in the meantime are kept or left out. - Playing while shuffled starts with the song chosen and shuffles the rest.
- Repeat all on the server is mpv's
loop-playlist. Next on the last song goes to the first, and Previous on the first goes to the last. The browser does the same itself. - Repeat one on the server is mpv's
loop-file. Next still moves on.
Watch for this
With loop-file, mpv plays the song again without an end-file event, so the usual play recording never fires. The only sign is a seek event back to the start. Player._on_event counts a seek event as a repeat, and records a play, unless it comes within OUR_SEEK_WINDOW (1 s) of a seek we asked for (seek(), or previous() restarting the song).
Dropouts and unplayable songs (This device)¶
On a patchy connection a stream can drop mid-song. BrowserOutput.failed() works out why the <audio> element gave up:
- Network error, or the server can't be reached (checked with a 2-byte request to the stream URL): it loads the same song again from where it stopped. Retries wait 1, 2, 4, 8, 15 and then 30 seconds, or happen at once when the browser fires
online. After 5 minutes it pauses, and Play tries again. While retrying, the state saysplaying, and Pause stops the retries. - The server has the file but the browser can't play it: one more try first (a cut-off stream can look like a broken file), then a skip. After three skips in a row it stops on the next song instead of running down the queue.
- Caching ahead: on
canplaythroughand on queue changes it asks the service worker to download the nextprefetchCount()songs (3 by default; Settings → Offline) (prefetchSongs()inoffline.js), unless the device asks to save data.
It reports what happened through a notice event, which Player turns into a toast ("Lost the connection. Trying again…", "Reconnected").
Whose plays count: a browser play is credited to the profile using that browser. A play on the server's speakers is credited to whoever last pressed play or added to the queue there (listener in app/api/player_routes.py).