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>
847 lines
35 KiB
Python
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
|