import math 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 import loudness 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 VOLUME_ADJUSTMENT_MAX = 255 # iTunes' unit: ±255 is its slider's ±100% def volume_with_adjustment(level: float, adjustment: int) -> float: """A track's volume adjustment (Get Info → Volume, iTunes' "Volume Adjustment", imported as-is) applied to the logical 0..1 volume. It scales the slider's own perceptual level: −100% is silence, +50% is the slider half again as far up. It can't go past full — no output can — so a boost only helps while the main volume is below the top, exactly like iTunes'.""" share = max(-1.0, min(1.0, adjustment / VOLUME_ADJUSTMENT_MAX)) return max(0.0, min(1.0, level * (1.0 + share))) _LOG100 = math.log(100.0) def volume_with_gain(level: float, gain_db: float) -> float: """The perceptual 0..1 `level` with a gain in dB on it (volume normalization). Done in the linear amplitude QAudioOutput plays, through Qt's own ``QAudio.convertVolume`` curve, so −6 dB really halves the signal. Can't pass full — a quiet song is only lifted while the slider has room above it.""" if not gain_db or level <= 0.0: return max(0.0, min(1.0, level)) linear = 1.0 if level >= 1.0 else -math.log(1.0 - level) / _LOG100 linear *= 10.0 ** (gain_db / 20.0) if linear >= 1.0: return 1.0 return 1.0 - math.exp(-linear * _LOG100) 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::") # 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 # Volume normalization: a LoudnessAnalyzer, injected (Player stays # manager-free), and the gain it implies for the playing song. A song # not measured yet starts at a typical song's gain and glides to its # own once the measurement lands, a fraction of a second later. self.loudness = None self._normalize = False self._gain_db = 0.0 self._gain_target = 0.0 self._glide = QTimer(self) self._glide.setInterval(self.GLIDE_STEP_MS) self._glide.timeout.connect(self._glide_step) 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._output_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._output_volume()) def refresh_volume(self): """Re-apply the volume — after the playing track's own volume adjustment was edited.""" self._sink.set_volume(self._output_volume()) def _output_volume(self) -> float: """The slider's volume with the playing track's adjustment and the normalization gain on it.""" return volume_with_gain(volume_with_adjustment( self._volume, getattr(self._current_track, "volume_adjustment", 0) or 0), self._gain_db) # ---- normalization ---- GLIDE_STEP_MS = 50 GLIDE_DB_PER_STEP = 0.5 # 10 dB a second: quick, but never a jump LOOKAHEAD = 2 # songs measured ahead of the playing one def set_normalize(self, on: bool): """Turn volume normalization on or off; applies to the playing song at once, gliding rather than jumping.""" on = bool(on) and self.loudness is not None if on == self._normalize: return self._normalize = on if on: self.loudness.measured.connect(self._on_measured) else: try: self.loudness.measured.disconnect(self._on_measured) except TypeError: pass self._aim_gain(self._current_track, glide=True) if on: self._measure_ahead() def is_normalizing(self) -> bool: return self._normalize def _aim_gain(self, track, glide: bool): """Point the gain at `track`'s measured loudness (or a typical song's while it's being measured). Without glide it lands at once — before a load, so a loud song never starts loud.""" target = 0.0 if self._normalize and track is not None and track.location: known, lufs = self.loudness.lufs(track.location) if not known: self.loudness.request(track.location, urgent=True) lufs = self.loudness.typical_lufs() target = loudness.gain_db(lufs) self._gain_target = target if glide: if not self._glide.isActive() and target != self._gain_db: self._glide.start() else: self._glide.stop() self._gain_db = target def _glide_step(self): step = self.GLIDE_DB_PER_STEP delta = self._gain_target - self._gain_db self._gain_db = (self._gain_target if abs(delta) <= step else self._gain_db + math.copysign(step, delta)) self._sink.set_volume(self._output_volume()) if self._gain_db == self._gain_target: self._glide.stop() def _on_measured(self, path: str, _lufs): track = self._current_track if track is not None and track.location == path: self._aim_gain(track, glide=True) def _measure_ahead(self): """Queue the next few songs, so they're measured before they play.""" if not self._normalize or not self._queue: return index = self._index order = (self._shuffle_order if self._shuffle else list(range(len(self._queue)))) try: at = order.index(index) except ValueError: return for i in order[at + 1:at + 1 + self.LOOKAHEAD]: track = self._resolve(self._queue[i]) if track is not None and track.location: self.loudness.request(track.location) 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 # Before the load, so a quieter track never starts loud. self._aim_gain(track, glide=False) self._sink.set_volume(self._output_volume()) self._measure_ahead() 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