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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| :--- | :--- |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user