Release 0.6.0: queue-aware gapless playback, clean playlist end

- Follow-up tracks are sent to the player only 20 s before they start, so
  queue changes in the app still take effect; emptied queue removes them
- Playlist end: pause before closing the stream and refuse reconnects to
  finished sessions (first track briefly replayed before)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 10:44:51 +01:00
co-authored by Claude Opus 5.5
parent 5f232a2093
commit 2bdec88b11
4 changed files with 77 additions and 27 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.6.0 – 2026-10-26
- Gapless: follow-up tracks are sent to the player only 20 s before they start, so queue changes in the
app (reordering, removing, adding tracks) take effect until then
- Gapless: an emptied queue removes the queued follow-up track
- Fix: at the end of a playlist the first track of the gapless stream briefly played again – BluOS
reconnected to the finished stream; the bridge now pauses before closing it and refuses reconnects
## 0.5.2 – 2026-10-25
- Fix: gapless mode showed every track twice as playing on the server – the content type is now taken
+2 -1
View File
@@ -140,7 +140,8 @@ if your network isn't trusted.
BluOS restarts its audio pipeline for every URL, so normally there is a short gap between tracks. With
`GAPLESS=true` (or the switch on the settings page) the bridge serves **one continuous stream** instead and
appends the next track as soon as your app announces it (`SetNextAVTransportURI`):
appends the next track your app announces (`SetNextAVTransportURI`) about 20 seconds before it is due – queue
changes in the app until then are respected:
| Format | How it's stitched |
| :--- | :--- |
+18 -7
View File
@@ -33,7 +33,7 @@ import xml.etree.ElementTree as ET
from email.utils import formatdate
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "0.5.2"
VERSION = "0.6.0"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -480,7 +480,7 @@ class Renderer:
self._close_session()
self.session = gapless.Session(self._segment(self.current_uri, self.current_meta), start_secs=seek)
if self.next_uri and self._proxied(self.next_uri):
self.session.offer_next(self._segment(self.next_uri, self.next_meta))
self.session.offer_next(self._segment(self.next_uri, self.next_meta), 0)
self.seg_index = 0
self.player_url = STREAMS.add_session(self.session, self._title(info))
log.info("play %r at %s (gapless via %s)", self._title(info) or "?", hms(seek), self.player_url)
@@ -569,9 +569,14 @@ class Renderer:
def set_next_uri(self, uri, meta):
with self.lock:
self.next_uri, self.next_meta = uri, meta
log.info("SetNextAVTransportURI %r", self._title(parse_didl(meta)))
if self.session and self.owned and not self.session.closed and uri and self._proxied(uri):
self.session.offer_next(self._segment(uri, meta))
log.info("SetNextAVTransportURI %r", self._title(parse_didl(meta)) if uri else "(none)")
if self.session and self.owned and not self.session.closed:
if uri and self._proxied(uri):
ok = self.session.offer_next(self._segment(uri, meta), self.seg_index)
else:
ok = self.session.clear_next(self.seg_index)
if not ok:
log.warning("gapless: next track already sent to the player, queue change applies after it")
self._notify_avt()
def play(self):
@@ -742,8 +747,12 @@ class Renderer:
(seg.duration and self.position >= seg.duration + 1) or state == "stop"):
# end of the stitched stream; a follow-up that couldn't be stitched starts normally
log.info("gapless stream finished at %r", seg.label)
self._close_session()
old = self.session
# pause (or start the unstitched follow-up) before closing the stream - otherwise BluOS
# reconnects to the ended stream and briefly plays its beginning again
self._track_ended()
if self.session is old:
self._close_session()
def _track_ended(self):
if self.next_uri:
@@ -1563,7 +1572,9 @@ class Handler(BaseHTTPRequestHandler):
def _stream_session(self, session, label):
"""Serve a gapless session as one endless stream (optionally with ICY titles)."""
if session.closed:
if session.closed or session.ended:
# BluOS reconnects to finished "radio" streams - don't replay the session from the start
log.info("gapless stream %r already finished, refusing reconnect", label)
return self._send(410, "", "text/plain")
# content type from getSong: a probe request would show up as a second playback on the server
ctype = session.segments[0].info.get("content_type") or "audio/mpeg"
+49 -19
View File
@@ -141,9 +141,10 @@ class Segment:
class Session:
"""One continuous stream made of several tracks."""
def __init__(self, first, start_secs=0):
def __init__(self, first, start_secs=0, lead_secs=20):
self.segments = [first]
self.start_secs = start_secs # stream begins this far into the first track
self.lead_secs = lead_secs # follow-up tracks are appended this long before they're due
self.cond = threading.Condition()
self.closed = False
self.ended = False # proxy finished writing (no more segments)
@@ -160,33 +161,58 @@ class Session:
def end_of_last(self):
return sum(s.duration for s in self.segments) - self.start_secs
def offer_next(self, seg):
"""Append or replace the queued follow-up track. Returns False if it's too late."""
def _queued(self):
"""The follow-up segment that hasn't been sent to the player yet (or None)."""
if len(self.segments) >= 2 and not self.segments[-1].streamed:
return self.segments[-1]
return None
def offer_next(self, seg, current_index):
"""Queue seg after the playing track, replacing a queued one that wasn't sent yet.
Returns False if a different track was already sent to the player (too late to change).
"""
with self.cond:
last = self.segments[-1]
if last.uri == seg.uri:
return True
if len(self.segments) >= 2 and not last.streamed and self._is_queued(last):
self.segments[-1] = seg
else:
queued = self._queued()
if queued is not None and len(self.segments) - 1 > current_index:
if queued.uri != seg.uri:
self.segments[-1] = seg
log.info("gapless: queued track changed to %r", seg.label)
elif len(self.segments) - 1 == current_index:
self.segments.append(seg)
elif self.segments[-1].uri != seg.uri:
return False
self.cond.notify_all()
return True
def _is_queued(self, seg):
# a segment is "queued" (replaceable) if playback hasn't reached it yet
idx = self.segments.index(seg)
return self.playback_secs < self.starts()[idx]
def clear_next(self, current_index):
"""Drop the queued follow-up track (queue emptied in the app). False if already sent."""
with self.cond:
if len(self.segments) - 1 <= current_index:
return True
if self._queued() is not None:
self.segments.pop()
log.info("gapless: queued track removed")
self.cond.notify_all()
return True
return False
def wait_segment(self, i, timeout_after_end=8):
"""Block until segment i exists. None if the session ends."""
"""Block until segment i exists and is due. None if the session ends.
Follow-up tracks are only sent lead_secs before they start, so queue changes in the app
until then still take effect.
"""
with self.cond:
while not self.closed and len(self.segments) <= i:
# nothing queued: give up shortly after playback reaches the end of the stream
if self.playback_secs >= self.end_of_last() + timeout_after_end:
while not self.closed:
if len(self.segments) > i:
if i == 0 or self.playback_secs >= self.starts()[i] - self.lead_secs:
return self.segments[i]
elif self.playback_secs >= self.end_of_last() + timeout_after_end:
# nothing queued: give up shortly after playback reaches the end of the stream
return None
self.cond.wait(1)
return None if self.closed else self.segments[i]
return None
def close(self):
with self.cond:
@@ -321,7 +347,11 @@ def stream_session(session, writer, kind_hint=None):
buf = buf[hlen:]
kind = kind or seg_kind
seg.streamed = True
with session.cond:
if session.closed or i >= len(session.segments) or session.segments[i] is not seg:
resp.close() # queue changed while the upstream was opening
continue
seg.streamed = True
writer.title = seg.label
log.info("gapless: streaming segment %d %r (%s)", i, seg.label, seg_kind)
# hold back the last 128 bytes so a trailing ID3v1 tag can be dropped