Files
lintunes/lintunes/player.py
trav d46374967d Fix silent resume after walking away: seek-in-place re-primes the sink
Rediagnosed (was thought Bluetooth-specific): on the Debian 13 machine,
resuming after the machine sat idle plays nothing even though decoding
runs (visualizer moves) — the OS suspends the audio sink and it comes
back dead until a seek re-primes it. Resuming after a pause of 30s+ now
does a seek-in-place before play(), automating the manual "rewind
slightly" workaround without losing the position. The volume re-apply
from the earlier fix stays (harmless, covers device swaps).

Also fold the new backlog items into the TASKS.md board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:56:11 -04:00

365 lines
15 KiB
Python

import random
import time
from pathlib import Path
from PyQt6.QtCore import QObject, QUrl, pyqtSignal
from PyQt6.QtMultimedia import (
QMediaPlayer, QAudioOutput, QAudioBufferOutput, QMediaDevices, QAudio)
from lintunes.models import Track
# 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
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 Player(QObject):
"""Playback engine: wraps QMediaPlayer and walks a queue of track ids.
The queue is the displayed order of whatever view playback started from.
Play counts are recorded (via LibraryManager) when a track finishes
playing naturally.
"""
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
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 start/stop times (ms) for the loaded track: seek to start once
# the media is seekable, and end the track early at stop.
self._pending_start_ms = 0
self._stop_at_ms = 0
self._counted_finish_id: int | None = None
self._paused_at: float | None = None # monotonic time of last pause
self._audio = QAudioOutput(self)
self._volume = 1.0 # logical 0..1; what the volume slider shows
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.position_changed)
self._media.positionChanged.connect(self._on_position)
self._media.durationChanged.connect(self.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)
self._shutdown_done = False
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
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
# ---- 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()
# ---- 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._media.playbackState() == QMediaPlayer.PlaybackState.PlayingState
def position_ms(self) -> int:
return self._media.position()
def duration_ms(self) -> int:
return self._media.duration()
# ---- volume ----
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."""
level = max(0.0, min(1.0, float(level)))
self._volume = 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."""
self._audio.setVolume(QAudio.convertVolume(
self._volume,
QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale,
))
def volume(self) -> float:
"""The logical 0..1 volume last set (what the slider shows)."""
return self._volume
# ---- transport ----
def toggle_play(self):
if self.is_playing():
self._paused_at = time.monotonic()
self._media.pause()
elif self._current_track is not None:
self._apply_volume()
if (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.
self._media.setPosition(self._media.position())
self._paused_at = None
self._media.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():
self._paused_at = time.monotonic()
self._media.pause()
def stop(self):
self._media.stop()
self._current_track = 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._media.position() > RESTART_THRESHOLD_MS or prev_index is None:
self._media.setPosition(0)
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._media.stop()
def seek(self, position_ms: int):
self._media.setPosition(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):
if not (0 <= self._index < len(self._queue)):
return
track = self._manager.library.tracks.get(self._queue[self._index])
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 recurse through the entire queue (RecursionError).
# 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
self._current_track = track
# Arm the custom start/stop times for this track. Stop is honored only
# when it falls before the end (otherwise EndOfMedia handles the finish).
self._pending_start_ms = track.start_time if track.start_time > 0 else 0
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._media.setSource(QUrl.fromLocalFile(track.location))
if autoplay:
self._media.play()
self.track_changed.emit(track)
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()
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):
self.playing_changed.emit(state == QMediaPlayer.PlaybackState.PlayingState)
def _on_media_status(self, status):
if (status == QMediaPlayer.MediaStatus.LoadedMedia
and self._pending_start_ms):
# The media is only reliably seekable once loaded; jump to the
# custom start time now (works whether or not we're autoplaying).
start = self._pending_start_ms
self._pending_start_ms = 0
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._note_finished(self._current_track)
# The player has already stopped at EndOfMedia, so force autoplay
self._advance(autoplay=True)
def _on_position(self, position):
"""Stop a track at its custom stop time and move on, mirroring a
natural finish (play count + scrobble)."""
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 or track.track_id == self._counted_finish_id:
return
self._counted_finish_id = track.track_id
self._manager.record_play(track.track_id)
self.track_finished.emit(track)
def _on_error(self, error, error_string):
track_name = self._current_track.name if self._current_track else "?"
self.error_occurred.emit(f"Playback error on '{track_name}': {error_string}")
self._skip_unplayable()