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()