diff --git a/TASKS.md b/TASKS.md index 703c61a..409f391 100644 --- a/TASKS.md +++ b/TASKS.md @@ -3,6 +3,15 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done. When a round closes, move its finished items to `tasks-done.md`. +## New backlog (added 2026-07-02, next round) + +- [ ] we might need an equalizer at some point but I'm not sure how it should show up +- [ ] I need to be able to drag a track or tracks from the gnome file browser into a playlist or just into the Library. It should copy the files into the 'itunes music' folder where we store all songs. It should store them where it makes sense for them to go 'artist -> album'. If there is no artist it should go in a standard unknown artist folder. The tracks should show up where they are dragged to in the playlist (if they're being dragged into a playlist) + _(Note: window/table drops + copy-to-`Artist/Album/` + unknown-artist fallback already exist — `file_importer.organized_destination`, `MainWindow.dropEvent`, `playlist_view.files_dropped`. Remaining gap to verify/build: inserting at the exact drop **position** in the playlist rather than appending.)_ +- [ ] if we rename an artist or album we should move the files. Basically we should keep the music folder organized in the same way the library itself is organized inside lintunes. If we add an artist the track moves from the unknown folder to the artist folder (make a folder if we need to) + _(Touches the "never move the user's music files" rule in CLAUDE.md — this would deliberately relax it for metadata renames; needs care around Syncthing + the other machine.)_ +- [ ] one of the sliders in the gray adjustments in preferences should be for the color of the equalizer visualizer. This will set the color of the gray when it's in gray mode. We still toggle between color, gray and off when clicking. Also what mode it's in (gray/color/off) should persist between launches of the app. + ## Round 17 (current) — TASKS.md batch + cruft cleanup Plan reference: `~/.claude/plans/can-you-take-a-drifting-sonnet.md`. @@ -30,11 +39,17 @@ Tests land in `tests/test_round17.py`. on non-JSON bodies ### Phases 2–4 — player & desktop integration -- [x] Task F: Bluetooth zero-volume after pause/resume — factored - `_apply_volume()`; re-applied on resume, after `setDevice`, and on - `BufferedMedia` (`player.py`) -- [ ] **verify Task F on the Bluetooth machine**: pause ≥30 s on BT - headphones, resume, must be audible without seeking +- [x] Task F: silent resume after pause — REDIAGNOSED per trav 2026-07-02: + not Bluetooth-specific; happens on the Debian 13 machine whenever he + walks away, resumes, and gets no sound despite the visualizer moving + (decoding runs, the idle-suspended audio sink comes back dead; a + slight rewind fixed it). Two-part fix in `player.py`: (1) + `_apply_volume()` re-applied on resume / `setDevice` / `BufferedMedia`; + (2) resume after a pause ≥30 s (`RESUME_NUDGE_THRESHOLD_S`) does a + **seek-in-place** first — the automated version of the manual rewind, + without losing the playback position. +- [ ] **verify Task F on the Debian 13 machine**: play, pause, walk away + ≥1 min, resume — must be audible without manually seeking - [x] Task E: exit segfault — idempotent `Player.shutdown()` (stop → clear source → detach buffer/audio outputs → disconnect QMediaDevices), called from `closeEvent` + `aboutToQuit`. @@ -68,7 +83,9 @@ Tests land in `tests/test_round17.py`. confirmation dialog with preview + Next Result, embed via `tagging.write_artwork` + size refresh, invalidate MPRIS art cache (new `art_search.py`, new `gui/album_art_dialog.py`, - `track_table.py`, `main_window.py`, `mpris.py`) + `track_table.py`, `main_window.py`, `mpris.py`). Follow-up 2026-07-02: + "Use for All N Songs in Album" button applies the art to every + library track on that album, not just the selection. - [ ] verify Tasks A/B/D by eye in the running app (hover/click ratings, search-bar alignment, art download on a real album) diff --git a/lintunes/player.py b/lintunes/player.py index 42b4d3f..993b3f3 100644 --- a/lintunes/player.py +++ b/lintunes/player.py @@ -1,4 +1,5 @@ import random +import time from pathlib import Path from PyQt6.QtCore import QObject, QUrl, pyqtSignal @@ -11,6 +12,12 @@ 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.""" @@ -51,6 +58,7 @@ class Player(QObject): 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 @@ -190,9 +198,18 @@ class Player(QObject): 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) @@ -204,6 +221,7 @@ class Player(QObject): def pause(self): if self.is_playing(): + self._paused_at = time.monotonic() self._media.pause() def stop(self): diff --git a/tests/test_round17.py b/tests/test_round17.py index 8089338..1b8101f 100644 --- a/tests/test_round17.py +++ b/tests/test_round17.py @@ -252,6 +252,23 @@ class TestVolumeReapply: assert applied == [True] player._media.play.assert_called_once() + def test_long_pause_resume_seeks_in_place(self, qapp, tmp_path, + monkeypatch): + import time as time_module + player = _mock_player(qapp, tmp_path) + player._current_track = Track(track_id=1, name="A") + monkeypatch.setattr(player, "is_playing", lambda: False) + # Short pause: no nudge. + player._paused_at = time_module.monotonic() - 5 + player.toggle_play() + player._media.setPosition.assert_not_called() + # Walk-away pause: seek-in-place re-primes the suspended sink. + player._media.position.return_value = 12345 + player._paused_at = time_module.monotonic() - 600 + player.toggle_play() + player._media.setPosition.assert_called_once_with(12345) + assert player._paused_at is None + # ---- Task B: search bar lives in the header strip ----