Measured once per file with ffmpeg's ebur128 (cached on this machine), applied as a gain on the output volume; the next songs are measured ahead and an unmeasured one glides to its level. Toggle in Controls and Preferences → General.
986 lines
40 KiB
Python
986 lines
40 KiB
Python
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:<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
|
||
# 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
|