Files
lintunes/lintunes/player.py
T
travandClaude Opus 5.5 ad1d152948 v0.35.0: a play history for the yearly Wrapped
The play journals keep running totals per track, which is enough for "most
played ever" and nothing a Wrapped asks: top songs of this year, minutes
listened, which month, what time of day, what got skipped. Those all come
from one timestamped line per listen, so LinTunes now keeps that, while
Preferences -> General -> "Keep a play history for my yearly Wrapped" is on.

history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals:
local start time with its offset, ms actually heard (Player adds position
steps under 1.5 s, so seeks and pauses don't count), how it ended, where it
played and from what, and a name/artist/album snapshot that outlives a
delete. Under 5 s is noise and never written.

andTunes 0.3.0 writes the same lines on the Rabbit when sync says the
desktop wants them, and sync copies each month home when the device's copy
is longer. "Share this year's play history with friends" puts a trimmed
copy (no ids, playlists or devices) in each friend's cassette folder, daily
in November and December and monthly otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:47:40 -07:00

847 lines
35 KiB
Python

import random
import time
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal
from PyQt6.QtMultimedia import (
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices, QAudio)
from lintunes.eventlog import log_control
from lintunes.models import Track
from lintunes.storage import listen_history
# Hitting "previous" more than this far into a song restarts it instead
RESTART_THRESHOLD_MS = 3000
# Resuming after a pause at least this long does a seek-in-place first: when
# the machine sits idle, the OS suspends the audio sink and it can come back
# silently dead — decoding continues (the visualizer moves) but nothing is
# audible until a seek re-primes the sink. Seen on trav's Debian 13 machine.
RESUME_NUDGE_THRESHOLD_S = 30
# The "parking brake". A merely paused/stopped QMediaPlayer keeps its PipeWire
# stream open and RUNNING, still holding the few seconds of audio the FFmpeg
# backend had decoded ahead. Nothing drains or corks it, so when the audio
# graph is later rewired — a USB DAC waking from idle suspend, a device
# appearing — that stale buffer flushes to the speakers: LinTunes "plays by
# itself" for ~4s, mid-song, hours after it was paused, and never records a
# play count. Diagnosed 2026-08-15 (the app sat Paused at 6974ms for 7h while
# PipeWire reported its stream state=running) and reproduced standalone on
# Qt 6.8.2 / FFmpeg 7.1.5.
#
# Qt owns that stream, so the fix has to be ours: once playback has been
# idle this long, tear the pipeline down and rebuild it on resume. Releasing
# the source is verified to remove the PipeWire node outright, so there is no
# buffer left to leak. Players built on GStreamer get this for free by corking
# on pause — if we ever move to a GStreamer sink, all of this goes away.
PARK_AFTER_IDLE_S = 30
# Listen accounting (the Wrapped history): a position step longer than this is
# a seek, not listening. Comfortably above the cast sink's 500 ms poll.
MAX_HEARD_STEP_MS = 1500
class _Listen:
"""How much of the current track has actually been heard."""
__slots__ = ("key", "track", "started", "heard", "last_pos")
def __init__(self, key, track, start_ms: int):
self.key = key
self.track = track
self.started = None # local time of the first audible step
self.heard = 0
self.last_pos = start_ms
def make_shuffle_order(count: int, start_index: int) -> list[int]:
"""A random permutation of range(count) that begins at start_index."""
order = [i for i in range(count) if i != start_index]
random.shuffle(order)
if 0 <= start_index < count:
order.insert(0, start_index)
return order
class PlaybackSink(QObject):
"""One audio output engine sitting behind Player.
Player owns the queue, the shuffle walk, the play-count/scrobble
bookkeeping and the custom start/stop times. A sink owns only "make this
file come out of something, and tell me where it's up to".
Everything a sink reports leaves as a Qt signal, so a sink whose events
originate on a foreign thread (the cast sink's pychromecast socket worker)
delivers them queued onto the GUI thread — the art_search/lastfm pattern.
"""
position_changed = pyqtSignal('qint64') # ms
duration_changed = pyqtSignal('qint64') # ms
state_changed = pyqtSignal(bool) # is it playing?
ended = pyqtSignal() # reached its natural end
error = pyqtSignal(str) # this track failed; Player skips
audio_buffer = pyqtSignal(object) # decoded PCM tee (local only)
unavailable = pyqtSignal(str) # the sink itself died
provides_pcm = False # does audio_buffer ever fire? (gates the visualizer)
name = "" # "" for local; the device name while casting
def load(self, track, autoplay: bool, start_ms: int):
raise NotImplementedError
def play(self):
raise NotImplementedError
def pause(self):
raise NotImplementedError
def stop(self):
raise NotImplementedError
def seek(self, position_ms: int):
raise NotImplementedError
def position_ms(self) -> int:
raise NotImplementedError
def duration_ms(self) -> int:
raise NotImplementedError
def is_playing(self) -> bool:
raise NotImplementedError
def set_volume(self, level: float):
"""Set output volume from a logical 0..1 value (0=silent, 1=full)."""
raise NotImplementedError
def can_play(self, track) -> bool:
"""False when this sink can't decode the track at all, so Player skips
past it rather than stalling on something that will never start."""
return True
def unplayable_reason(self, track) -> str:
return ""
def shutdown(self):
pass
class LocalSink(PlaybackSink):
"""The built-in sink: QMediaPlayer + QAudioOutput, with the PCM tee.
Stays in player.py on purpose — the Player tests stub the Qt Multimedia
classes with ``patch.multiple(player_module, QMediaPlayer=..., ...)``, so
they have to be looked up in this module's namespace.
"""
provides_pcm = True
def __init__(self, parent=None):
super().__init__(parent)
self._volume = 1.0 # logical 0..1; what the volume slider shows
# Custom start time (ms) for the loaded track: applied once the media
# reports itself loaded, which is when it is reliably seekable. Tagged
# with the URL it was armed for, because setSource() fires a LoadedMedia
# for the *outgoing* media that would otherwise consume it (see load()).
self._pending_start_ms = 0
self._pending_start_url = QUrl()
self._paused_at: float | None = None # monotonic time of last pause
self._shutdown_done = False
# Parking-brake state (see PARK_AFTER_IDLE_S). While parked there is no
# pipeline at all, so position/duration are served from these caches
# and the media object's own signals are suppressed.
self._parked = False
self._parked_url = QUrl()
self._parked_position_ms = 0
self._parked_duration_ms = 0
self._park_timer = QTimer(self)
self._park_timer.setSingleShot(True)
self._park_timer.setInterval(int(PARK_AFTER_IDLE_S * 1000))
self._park_timer.timeout.connect(self._park)
self._audio = QAudioOutput(self)
self._media = QMediaPlayer(self)
self._media.setAudioOutput(self._audio)
# Tee of the decoded PCM, feeding the visualizer
self._buffer_output = QAudioBufferOutput(self)
self._media.setAudioBufferOutput(self._buffer_output)
self._buffer_output.audioBufferReceived.connect(
lambda buf: self.audio_buffer.emit(buf))
# Follow the system default output so audio keeps playing when the
# active device changes (e.g. headphones unplugged). Held as an
# attribute so it isn't garbage-collected and keeps emitting.
self._media_devices = QMediaDevices(self)
self._media_devices.audioOutputsChanged.connect(
self._on_audio_outputs_changed)
self._media.positionChanged.connect(self._on_position_changed)
self._media.durationChanged.connect(self._on_duration_changed)
self._media.playbackStateChanged.connect(self._on_state_changed)
self._media.mediaStatusChanged.connect(self._on_media_status)
self._media.errorOccurred.connect(self._on_error)
def load(self, track, autoplay: bool, start_ms: int):
start_ms = max(0, start_ms)
# A new track supersedes anything parked; the source below rebuilds the
# pipeline anyway, and the cached position belongs to the old track.
self._park_timer.stop()
self._parked = False
self._parked_url = QUrl()
url = QUrl.fromLocalFile(track.location)
if self._media.source() == url:
# setSource() no-ops on an unchanged URL, so LoadedMedia never
# re-fires and the armed start time would be skipped (and forcing
# a clear+reload races the FFmpeg backend, which snaps the seek
# back to 0). The media is already loaded: rewind and seek now.
self._media.stop()
self._pending_start_ms = 0
self._pending_start_url = QUrl()
if start_ms:
self._media.setPosition(start_ms)
else:
# setSource() synchronously emits a *stale* LoadedMedia that still
# reports the outgoing media (the previous track dropping back from
# BufferedMedia) before it starts loading the new one. Tagging the
# armed seek with its URL keeps that event from eating it — the
# real LoadedMedia lands a few ms later, and until Round 32 the
# start time was silently lost on every track change.
self._pending_start_ms = start_ms
self._pending_start_url = url
self._media.setSource(url)
if autoplay:
self._media.play()
def play(self):
self._park_timer.stop()
if self._parked:
self._unpark() # rebuilds the pipeline and re-arms the seek
elif (self._paused_at is not None
and time.monotonic() - self._paused_at
>= RESUME_NUDGE_THRESHOLD_S):
# Re-prime a possibly idle-suspended sink (the manual workaround
# was "rewind slightly"); seek in place so the listener doesn't
# lose their spot. Rarely reached now that a pause this long parks
# instead — a rebuilt pipeline can't be idle-suspended-dead — but
# kept as the fallback for a park that didn't happen.
self._media.setPosition(self._media.position())
self._apply_volume()
self._paused_at = None
self._media.play()
def pause(self):
self._paused_at = time.monotonic()
self._media.pause()
self._park_timer.start()
def stop(self):
# Also parked: Qt's stop() leaves the source loaded, so the stream
# survives here too — this is the path _advance() takes when a playlist
# runs out, which would otherwise sit loaded all night.
self._media.stop()
self._park_timer.start()
def seek(self, position_ms: int):
position_ms = max(0, position_ms)
if self._parked:
# No pipeline to seek; move the resume point instead and keep the
# scrubber honest.
self._parked_position_ms = position_ms
self.position_changed.emit(position_ms)
return
self._media.setPosition(position_ms)
def position_ms(self) -> int:
return self._parked_position_ms if self._parked else self._media.position()
def duration_ms(self) -> int:
return self._parked_duration_ms if self._parked else self._media.duration()
def is_playing(self) -> bool:
return self._media.playbackState() == QMediaPlayer.PlaybackState.PlayingState
def set_volume(self, level: float):
"""Set output volume from a logical 0..1 value (0=silent, 1=full).
The slider scale is perceptual, so convert to the linear gain
QAudioOutput expects — that makes the knob feel iTunes-like rather
than jumping to "loud" in the first few percent."""
self._volume = max(0.0, min(1.0, float(level)))
self._apply_volume()
def _apply_volume(self):
"""Push the stored logical volume to the sink. Called again on resume
and after device swaps: some backends (seen with Bluetooth sinks)
re-create the sink with a stale/zero gain, which played silently until
a seek re-primed it. Forced to silence while parked, so moving the
volume slider can't re-arm a buffer the teardown failed to drop."""
level = 0.0 if self._parked else self._volume
self._audio.setVolume(QAudio.convertVolume(
level,
QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale,
))
# ---- the parking brake ----
def _park(self):
"""Release the audio pipeline after a long idle (see PARK_AFTER_IDLE_S).
Two independent layers, because this is guarding someone's sleep:
the gain goes to zero first (so anything already buffered downstream
drains silently even if the teardown misbehaves), then the source is
released, which removes the PipeWire stream outright — verified on
Qt 6.8.2 — leaving nothing that could ever flush to the speakers.
"""
if self._parked or self._shutdown_done or self.is_playing():
return
if self._media.source().isEmpty():
return # nothing loaded; nothing to release
self._parked_url = self._media.source()
self._parked_position_ms = self._media.position()
self._parked_duration_ms = self._media.duration()
# Set before teardown so the stop/clear churn below can't reach the UI
# as a jump to 0:00, and so _apply_volume() forces silence.
self._parked = True
self._apply_volume()
self._media.stop()
self._media.setSource(QUrl()) # this is what drops the stream
log_control("player", "park",
f"released the audio pipeline at "
f"{self._parked_position_ms}ms")
def _unpark(self):
"""Rebuild the pipeline parked by _park() and resume where we left off.
The seek rides the existing custom-start-time machinery: arming
_pending_start_* makes _on_media_status() jump there once the media is
loaded, which is the only point it is reliably seekable.
"""
url = self._parked_url
start = self._parked_position_ms
self._parked = False
self._parked_url = QUrl()
self._apply_volume() # back to the real gain
if url.isEmpty():
return
self._pending_start_ms = start
self._pending_start_url = url
self._media.setSource(url)
log_control("player", "unpark",
f"rebuilt the audio pipeline at {start}ms")
def _on_position_changed(self, position):
if not self._parked:
self.position_changed.emit(position)
def _on_duration_changed(self, duration):
if not self._parked:
self.duration_changed.emit(duration)
def shutdown(self):
"""Tear down the Qt Multimedia pipeline in a safe order before Qt
destroys the objects. Without this, QObject children are destroyed in
creation order at exit — the QAudioOutput dies while the QMediaPlayer
(FFmpeg backend) still references it, which segfaults after the GUI is
gone. Idempotent; wired to both closeEvent and aboutToQuit."""
if self._shutdown_done:
return
self._shutdown_done = True
self._park_timer.stop()
try:
self._media_devices.audioOutputsChanged.disconnect(
self._on_audio_outputs_changed)
except TypeError:
pass
self._media.stop()
self._media.setSource(QUrl()) # release the demux/decode pipeline
self._media.setAudioBufferOutput(None)
self._media.setAudioOutput(None) # detach the sink while both live
def _on_audio_outputs_changed(self):
"""Re-point the output at the current default device when the set of
available outputs changes (a device was added or removed)."""
new_default = QMediaDevices.defaultAudioOutput()
if new_default.isNull() or new_default.id() == self._audio.device().id():
return
was_playing = self.is_playing()
self._audio.setDevice(new_default)
self._apply_volume() # the new sink may come up at a stale/zero gain
# Some backends stall the sink across a device swap; nudge it back.
# play() is a no-op if playback actually continued.
if was_playing:
self._media.play()
def _on_state_changed(self, state):
if self._parked:
return # the teardown's own Stopped, not a user-visible change
self.state_changed.emit(state == QMediaPlayer.PlaybackState.PlayingState)
def _on_media_status(self, status):
if self._parked:
return # statuses from tearing the pipeline down
if (status == QMediaPlayer.MediaStatus.LoadedMedia
and self._pending_start_ms
and self._media.source() == self._pending_start_url):
# The media is only reliably seekable once loaded; jump to the
# custom start time now (works whether or not we're autoplaying).
# The source check rejects the stale LoadedMedia that setSource()
# fires for the media being replaced.
start = self._pending_start_ms
self._pending_start_ms = 0
self._pending_start_url = QUrl()
self._media.setPosition(start)
return
if status == QMediaPlayer.MediaStatus.BufferedMedia:
self._apply_volume() # sink can be re-created per source
return
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.ended.emit()
def _on_error(self, error, error_string):
self.error.emit(error_string)
class Player(QObject):
"""Playback engine: walks a queue of track ids through a swappable sink.
The queue is the displayed order of whatever view playback started from.
Play counts are recorded (via LibraryManager) when a track finishes
playing naturally — on whichever sink is active, so casting scrobbles and
counts plays exactly like local playback does.
"""
track_changed = pyqtSignal(object) # Track or None
playing_changed = pyqtSignal(bool)
position_changed = pyqtSignal('qint64') # ms
duration_changed = pyqtSignal('qint64') # ms
error_occurred = pyqtSignal(str)
track_missing = pyqtSignal(object) # Track whose file is gone/moved
audio_buffer = pyqtSignal(object) # QAudioBuffer (decoded PCM)
track_finished = pyqtSignal(object) # Track played to the very end
track_unplayable = pyqtSignal(object, str) # skipped: sink can't decode it
sink_changed = pyqtSignal(object) # the new PlaybackSink
# One listen ended: (queue key, Track, local start datetime, ms heard,
# "finish" | "skip" | "stop" | "quit"). Feeds the Wrapped history; the
# listener decides whether it's kept and adds the context.
listen_ended = pyqtSignal(object, object, object, int, str)
# Signals relayed from whichever sink is active.
_SINK_SIGNALS = ("position_changed", "duration_changed", "state_changed",
"ended", "error", "audio_buffer", "unavailable")
def __init__(self, manager, parent=None):
super().__init__(parent)
self._manager = manager
self._queue: list[int] = []
self._index = -1
self._current_track: Track | None = None
self._shuffle = False
self._shuffle_order: list[int] = []
# Custom stop time (ms) for the loaded track: end it early. Enforced
# here rather than in a sink so both playback paths honor it.
self._stop_at_ms = 0
self._counted_finish_id: int | None = None
# Queue entries are my track ids (int) or, for a friend's followed
# playlist, opaque string keys ("cassette:<token>:<their id>")
# resolved through this callback — Player stays manager-free about
# friends. Plays of friend tracks scrobble but don't count in my
# journal.
self.track_resolver = None # Callable[[str], Track | None]
self._current_key = None
self._listen: _Listen | None = None
self._volume = 1.0 # logical 0..1; what the volume slider shows
self._local = LocalSink(self)
self._sink: PlaybackSink = self._local
self._connect_sink(self._sink)
self._shutdown_done = False
# ---- sinks ----
def _connect_sink(self, sink: PlaybackSink):
sink.position_changed.connect(self.position_changed)
sink.position_changed.connect(self._on_position)
sink.duration_changed.connect(self.duration_changed)
sink.state_changed.connect(self.playing_changed)
sink.ended.connect(self._on_sink_ended)
sink.error.connect(self._on_sink_error)
sink.audio_buffer.connect(self.audio_buffer)
sink.unavailable.connect(self._on_sink_unavailable)
def _disconnect_sink(self, sink: PlaybackSink):
for name in self._SINK_SIGNALS:
try:
getattr(sink, name).disconnect()
except TypeError:
pass
def active_sink(self) -> PlaybackSink:
return self._sink
@property
def is_local(self) -> bool:
"""Playing through this computer's own output (not a Chromecast)."""
return self._sink is self._local
def provides_pcm(self) -> bool:
return self._sink.provides_pcm
def set_sink(self, sink: PlaybackSink | None):
"""Swap the output engine; None restores the built-in local one.
The current track and position carry across and playback resumes in
whatever state it was in, so connecting or losing a Chromecast picks up
mid-song rather than restarting it.
`track_changed` is deliberately *not* re-emitted: to the listener this
is the same song still playing, and re-emitting would fire a second
Last.fm now-playing update and reset an in-progress bpm tap.
"""
new_sink = sink if sink is not None else self._local
if new_sink is self._sink:
return
track = self._current_track
position = self._sink.position_ms()
was_playing = self._sink.is_playing()
self._sink.stop()
self._disconnect_sink(self._sink)
self._sink = new_sink
self._connect_sink(self._sink)
# Whatever the slider last showed applies to the new output too.
self._sink.set_volume(self._volume)
if track is not None:
if self._sink.can_play(track):
self._sink.load(track, autoplay=was_playing, start_ms=position)
else:
self.track_unplayable.emit(
track, self._sink.unplayable_reason(track))
self._skip_unplayable(autoplay=was_playing)
self.sink_changed.emit(self._sink)
def shutdown(self):
"""Tear down every sink. Idempotent; wired to closeEvent/aboutToQuit."""
if self._shutdown_done:
return
self._shutdown_done = True
self._close_listen(listen_history.QUIT)
self._sink.shutdown()
if self._sink is not self._local:
# The local sink is kept alive across a swap so switching back is
# instant — which means its Qt Multimedia pipeline is still around
# at quit and still needs its ordered teardown.
self._local.shutdown()
# ---- queue ----
def play_queue(self, track_ids: list[int], start_index: int):
self._queue = list(track_ids)
self._index = start_index
self._reshuffle()
self._load_current(autoplay=True)
def set_queue(self, track_ids: list[int]):
"""Replace the upcoming order without interrupting the current track."""
current_id = self._queue[self._index] if 0 <= self._index < len(self._queue) else None
self._queue = list(track_ids)
if current_id is not None and current_id in self._queue:
self._index = self._queue.index(current_id)
self._reshuffle()
def drop_tracks(self, track_ids):
"""Forget tracks that no longer exist in the library.
Stops if one of them is playing; otherwise the current track keeps its
place in the shortened queue. Without this the queue keeps dead ids and
``_load_current`` stops the whole thing with an error dialog when the
walk eventually reaches one.
"""
doomed = set(track_ids)
if not doomed:
return
if self._current_track is not None and self._current_track.track_id in doomed:
self.stop()
current_id = None
else:
current_id = (self._queue[self._index]
if 0 <= self._index < len(self._queue) else None)
self._queue = [tid for tid in self._queue if tid not in doomed]
self._index = (self._queue.index(current_id)
if current_id is not None and current_id in self._queue else -1)
self._reshuffle()
# ---- shuffle ----
def is_shuffle(self) -> bool:
return self._shuffle
def set_shuffle(self, on: bool):
"""Shuffle only changes how we *walk* the queue; the playlist's
order and display are untouched."""
self._shuffle = bool(on)
self._reshuffle()
def _reshuffle(self):
if self._shuffle and self._queue:
self._shuffle_order = make_shuffle_order(
len(self._queue), max(self._index, 0))
else:
self._shuffle_order = []
def _step_index(self, delta: int) -> int | None:
"""Queue index `delta` steps away in the active walking order."""
if not self._queue:
return None
if not self._shuffle:
candidate = self._index + delta
return candidate if 0 <= candidate < len(self._queue) else None
try:
pos = self._shuffle_order.index(self._index)
except ValueError:
return self._shuffle_order[0] if self._shuffle_order else None
pos += delta
if 0 <= pos < len(self._shuffle_order):
return self._shuffle_order[pos]
return None
# ---- state ----
@property
def current_track(self) -> Track | None:
return self._current_track
def is_playing(self) -> bool:
return self._sink.is_playing()
def position_ms(self) -> int:
return self._sink.position_ms()
def duration_ms(self) -> int:
return self._sink.duration_ms()
# ---- volume ----
def set_volume(self, level: float):
"""Set output volume from a logical 0..1 value (0=silent, 1=full).
The value is kept here so it survives a sink swap — the slider keeps
meaning the same thing whether audio is coming out of this machine or
a Chromecast."""
self._volume = max(0.0, min(1.0, float(level)))
self._sink.set_volume(self._volume)
def volume(self) -> float:
"""The logical 0..1 volume last set (what the slider shows)."""
return self._volume
# ---- transport ----
def toggle_play(self):
log_control("player", "toggle_play",
f"playing={self.is_playing()} pos={self.position_ms()}ms "
f"track={self._current_track.name if self._current_track else None!r}")
if self.is_playing():
self._sink.pause()
elif self._current_track is not None:
if self._listen is None:
# Replaying the last track after the queue ran out.
self._listen = _Listen(self._current_key, self._current_track,
self._sink.position_ms())
self._sink.play()
elif self._queue:
self._index = max(self._index, 0)
self._load_current(autoplay=True)
def play(self):
if not self.is_playing():
self.toggle_play()
def pause(self):
if self.is_playing():
log_control("player", "pause", f"pos={self.position_ms()}ms")
self._sink.pause()
def stop(self):
self._close_listen(listen_history.STOP)
self._sink.stop()
self._current_track = None
self._current_key = None
self.track_changed.emit(None)
def next(self):
# When paused/stopped, skipping only cues the new track; audio
# starts only if we were already playing.
self._advance(autoplay=self.is_playing())
def previous(self):
if not self._queue:
return
prev_index = self._step_index(-1)
if self.position_ms() > RESTART_THRESHOLD_MS or prev_index is None:
# "Restart" means the track's custom start time, not 0:00.
start = (self._current_track.start_time
if self._current_track else 0)
self._sink.seek(max(0, start))
else:
self._index = prev_index
self._load_current(autoplay=self.is_playing())
def _advance(self, autoplay: bool):
next_index = self._step_index(1)
if next_index is not None:
self._index = next_index
self._load_current(autoplay=autoplay)
elif self._queue:
self._sink.stop()
def seek(self, position_ms: int):
self._sink.seek(max(0, position_ms))
def retry_current(self):
"""Re-attempt the track at the current queue position (e.g. after its
location was repaired). stop() leaves _queue/_index intact."""
self._load_current(autoplay=True)
# ---- internals ----
def _load_current(self, autoplay: bool):
"""Load the queue entry at _index, stepping past tracks the active
sink can't decode (a Chromecast can't play ALAC, AIFF or protected
AAC). The skip is a loop, not recursion: shuffling into a run of
unsupported tracks must not walk the stack down."""
while 0 <= self._index < len(self._queue):
key = self._queue[self._index]
track = self._resolve(key)
if track is None:
# The queue references a track that's no longer in the library;
# there's nothing to locate, so just report and stop.
self.error_occurred.emit(
f"Can't play “track {self._queue[self._index]}”.")
self.stop()
return
if not track.location or not Path(track.location).exists():
# Stop here rather than auto-advancing: if the whole drive is
# unmounted every queued track is missing, and skipping to the
# next would walk the entire queue for nothing.
# Stop *before* emitting: the UI shows a modal "Locate File…"
# dialog synchronously from this signal and may relocate +
# retry, which a later stop() would otherwise undo.
self.stop()
self.track_missing.emit(track)
return
if not self._sink.can_play(track):
# Reported on its own signal, not error_occurred: that one
# opens a modal dialog, and shuffling past 100 lossless files
# must not mean 100 dialogs.
self.track_unplayable.emit(
track, self._sink.unplayable_reason(track))
next_index = self._step_index(1)
if next_index is None:
self.stop()
return
self._index = next_index
continue
self._close_listen(listen_history.SKIP)
start_ms = track.start_time if track.start_time > 0 else 0
self._listen = _Listen(key, track, start_ms)
self._current_track = track
self._current_key = key
# Arm the custom stop time for this track. It's honored only when
# it falls before the end (otherwise the natural finish handles it).
if track.stop_time > 0 and (track.total_time == 0
or track.stop_time < track.total_time):
self._stop_at_ms = track.stop_time
else:
self._stop_at_ms = 0
self._counted_finish_id = None
self._sink.load(
track, autoplay,
track.start_time if track.start_time > 0 else 0)
self.track_changed.emit(track)
return
def _skip_unplayable(self, autoplay: bool = True):
next_index = self._step_index(1)
if next_index is not None:
self._index = next_index
self._load_current(autoplay)
else:
self.stop()
# ---- sink events ----
def _on_sink_ended(self):
"""The active sink played the track to its natural end."""
self._note_finished(self._current_track)
# Playback has already stopped at the end, so force autoplay.
self._advance(autoplay=True)
def _on_sink_error(self, message: str):
track_name = self._current_track.name if self._current_track else "?"
self.error_occurred.emit(f"Playback error on '{track_name}': {message}")
self._skip_unplayable()
def _on_sink_unavailable(self, reason: str):
"""The sink itself died (the Chromecast went away). Drop back to local
playback, which picks up at the same position, and say why."""
self.set_sink(None)
if reason:
self.error_occurred.emit(reason)
def _on_position(self, position):
"""Stop a track at its custom stop time and move on, mirroring a
natural finish (play count + scrobble)."""
self._account_heard(position)
if self._stop_at_ms and position >= self._stop_at_ms:
self._stop_at_ms = 0 # one-shot: don't re-fire while advancing
track = self._current_track
self._note_finished(track)
self._advance(autoplay=True)
def _note_finished(self, track):
"""Record a completed play once, guarding against a double count if
both the stop-time and end-of-media paths fire for the same track."""
if track is None:
return
key = (self._current_key if track is self._current_track
and self._current_key is not None else track.track_id)
if key == self._counted_finish_id:
return
self._counted_finish_id = key
if not isinstance(key, str):
self._manager.record_play(track.track_id)
if track is self._current_track:
self._close_listen(listen_history.FINISH)
self.track_finished.emit(track)
# ---- listen accounting ----
def _account_heard(self, position: int):
listen = self._listen
if listen is None:
return
step = position - listen.last_pos
listen.last_pos = position
if 0 < step <= MAX_HEARD_STEP_MS:
if listen.started is None:
listen.started = listen_history.local_now()
listen.heard += step
def _close_listen(self, how: str):
listen, self._listen = self._listen, None
if listen is None or not listen.heard:
return
self.listen_ended.emit(listen.key, listen.track, listen.started,
listen.heard, how)
def _resolve(self, key):
if isinstance(key, str):
return self.track_resolver(key) if self.track_resolver else None
return self._manager.library.tracks.get(key)
@property
def current_key(self):
"""The queue entry playing: a track id, or a friend track's key."""
return self._current_key