Skip to content

feat: shuffle and repeat for Local Files, Subsonic, and YouTube#373

Merged
LargeModGames merged 9 commits into
mainfrom
feature/repeat-shuffle-other-sources
Jul 15, 2026
Merged

feat: shuffle and repeat for Local Files, Subsonic, and YouTube#373
LargeModGames merged 9 commits into
mainfrom
feature/repeat-shuffle-other-sources

Conversation

@LargeModGames

@LargeModGames LargeModGames commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Closes #367.

Shuffle (Ctrl+s) and repeat (Ctrl+r) now work when one of the decoded sources owns playback, not just on Spotify.

What this adds

  • Repeat cycles Off → All → Track for Local Files, Subsonic, and YouTube, mirroring Spotify. All wraps the context from the last track back to the first; Track replays the current song via IoEvent::ReplayCurrentTrack (remote sources replay the retained tempfile, so no re-download).
  • Shuffle reorders in place and keeps the currently playing song playing (it becomes the new first track), so toggling never interrupts audio. Turning it off restores the original order and your place in it, via a stored permutation, so it is correct even with duplicate tracks.
  • Both modes, plus the shuffle order itself, survive a restart in last_session.yml, and are routed through MPRIS so playerctl shuffle/loop and desktop media controls drive them.

Internet radio and the native queue slot have no track queue of their own, so they drop the shuffle/repeat controls rather than showing inert ones.

Design

Player-global App::decoded_repeat / App::decoded_shuffle, with the decision logic as pure, unit-tested helpers in src/infra/queue/mod.rs (RepeatMode, advance_decision, advance_index, resume_index_after_queue, toggle_shuffle/ShuffleBackup). The per-source dispatchers share those helpers, so the three sources cannot drift apart.

The native queue engine is also re-gated from feature = "audio-decode" onto the queueable sources (local-files/subsonic/youtube). Internet radio decodes audio but is never a queue item, so a radio-only build previously compiled the whole queue engine and never used it. cargo check --no-default-features --features internet-radio is now warning-free.

Two subtleties worth a reviewer's attention

Option<usize> carries two meanings. To advance_index, None means "clamp, the skip is a no-op". To every resume_index_after_queue consumer, it means "context exhausted, tear down". Those coincide only under RepeatMode::Off. Under Repeat One that conflation stopped playback dead, so resume_index_after_queue now never returns None for an in-range index under Track.

Repeat All removes a terminator. Local playback deliberately skips past unplayable files, which self-terminates under Off (the last track has no next) but wraps forever under Context. Failures are now bounded to the distinct tracks that exist, and the teardown happens under the same lock that observes exhaustion. Clearing the guard and unlocking first raced the runner tick into leaking a NextTrack to the user's real Spotify device.

One invariant, four surfaces. "The queue slot and radio have no shuffle/repeat" has to hold on the keyboard, the playbar controls, the MPRIS snapshot, and the MPRIS setters. Each one had a different hole; a mismatch meant a keypress silently mutating your real Spotify device with nothing on screen to show for it.

Testing

  • cargo fmt, cargo clippy clean on both the slim CI feature set and --features all-sources.
  • 723 tests pass under all-sources, 436 under the slim set. New unit tests cover the repeat/shuffle state machine, the queue-handoff resume indices, and the playbar control matrix.
  • All four single-source builds (local-files, subsonic, youtube, internet-radio) compile warning-free.

Known gaps

  • infra::local::tests::uri_round_trip fails on Windows (hardcoded Unix path). Pre-existing, verified failing identically on main, and invisible to CI because local-files is not in the slim feature set.
  • The MPRIS (Linux) and macOS Now Playing changes could not be compiled on the authoring machine. CI's ubuntu-latest job covers MPRIS; macOS is only built in cd.yml on release, so the one-line macOS filter has no PR-time compile check.

Pre-existing issues found but deliberately left alone

Out of scope here, worth follow-up issues: queue-slot Play/Pause misroutes to a Web-API resume for a Spotify slot; keyboard Like/Shift+W acts on the suspended context rather than the audible track; a Lua plugin's IoEvent::Repeat still reaches Spotify during radio.

Summary by CodeRabbit

  • New Features

    • Added Shuffle and Repeat controls for local, Subsonic, and YouTube playback.
    • Repeat supports Off, Repeat All, and Repeat Track modes.
    • Shuffle and Repeat settings persist across sessions and sync with MPRIS controls.
    • Playbar status templates now support {shuffle} and {repeat} placeholders.
  • Bug Fixes

    • Improved queue navigation, repeat-one replay, failed-track handling, and control visibility.
    • Shuffle and Repeat controls are hidden where unsupported, including internet radio and native queues.
  • Documentation

    • Updated configuration examples and the changelog with the new playback controls.

LargeModGames and others added 9 commits July 13, 2026 23:46
- Wrap decoded context to the first track under Repeat All when suspending
  to the native queue (was stopping at the boundary)
- Persist decoded repeat mode, shuffle intent, and the shuffle permutation;
  restore them on launch and reconcile a deferred toggle on the next tick
- Route MPRIS shuffle/repeat setters and explicit IoEvent::Repeat to the
  decoded state when a queueable source owns playback
- Hide shuffle/repeat controls for radio and native-queue slots in the
  playbar and MPRIS snapshots (self-contained placeholder segments)
- Push local-file decoded modes to MPRIS and reset loop state for mode-less
  sources
- Defer shuffle reordering while a track change is in flight to avoid a
  stale play_index target; validate persisted permutations before indexing
- Broaden the std::sync::Arc import gate to audio-decode so decoded-only
  builds compile; silence the resulting dead-code warnings
Encode repo conventions as path instructions (IoEvent dispatch, keybinding
lookups, dialog cleanup, slim-build feature gating) and point the knowledge
base at CLAUDE.md, AGENTS.md and copilot-instructions.md.

Raise the github-checks timeout to 15m so reviews wait for the Rust matrix
build instead of giving up at the 90s default, skip dependabot and
allcontributors PRs, and drop the docstring coverage check.

.luacheckrc declares the injected `spotatui` global so the example plugins
do not trip undefined-variable warnings.
Temporary, not for main. Carries the write-up between machines while the
native-queue re-gating fix is in progress. See the cleanup section in the
file before removing it.
A queued song preempts a decoded context via Decision::SuspendToQueue, which
advance_decision returns as soon as the queue is non-empty, before it ever
consults the repeat mode. The suspend path then computed its resume index with
advance_index(.., forward: true), where RepeatMode::Track shares the clamping
arm with Off and yields current + 1.

So when a track ended with Repeat One on and anything queued, the repeated track
was silently skipped (track 5 of 10 resumed at 6), and on the last track
advance_index returned None, which reads as "context exhausted" and tore the
whole context down instead of resuming it.

Add resume_index_after_queue, taking a SuspendCause because the two handoffs to
the queue need opposite rules under Repeat One:

  - AutoAdvance (the track ended on its own): replay the current track, so a
    queued song cannot consume the repeat.
  - ManualSkip (the user pressed Next): advance past it, matching the per-source
    skip paths. Repeat-one only replays on *auto* advance.

Every other mode defers to advance_index either way: Context still wraps last to
first, Off still clamps to None. advance_index itself is unchanged -- it is
shared with the manual Next/Previous paths, whose repeat-one clamping is already
correct.
Add the Unreleased "Added" entry for shuffle/repeat on the decoded sources.

docs/configuration.md was left behind when the default playbar_status_source
template gained {shuffle}/{repeat}: it documented the old default and claimed
"the defaults above reproduce the built-in output exactly", which was no longer
true. It also said both playbar templates accept the same keys without noting
that {shuffle}/{repeat} are bare values in playbar_status but come pre-composed
with their own " | Label: " prefix in playbar_status_source, so the segment can
vanish for sources without the control.
The queue engine's presence was keyed off `audio-decode`, but its use requires a
source with a finite track list. Internet radio enables `audio-decode` (it
decodes a live stream) without being queueable, so a radio-only build compiled
the whole queue-for-decoded-tracks machinery and never used it: 9 dead-code
warnings from `cargo check --no-default-features --features internet-radio`.

Re-gate those items on `any(local-files, subsonic, youtube)` -- the set that
actually constructs `QueueNowPlaying::Decoded` and drives the fetch/advance/
resume paths. The queue-slot field and its accessors move to
`any(streaming, local-files, subsonic, youtube)`, since a slot can also hold a
native-streamed Spotify track. Genuinely shared decode/output machinery that
radio does use (the rodio sink, the local playbar view) stays on `audio-decode`.

This is pure gating: no logic or control flow changes. The re-gated code was
provably unreachable in the affected configs -- `try_play_queued` has no radio
arm, so `queue_now` was permanently `None` there, which is what the compiler was
reporting.

Also fixes a latent compile error the warnings were hiding: the `stray_over_queue`
guard in player/events.rs sits inside streaming-gated code, so `streaming,
internet-radio` failed to build. Neither the radio-only build nor CI's full
matrix (which enables the queueable sources too) covered that combination.

Verified warning- and error-free across every single-source build, radio paired
with each queueable source, streaming+internet-radio, all-sources, and both CI
clippy gates.
The controls row rendered all eight buttons from the unfiltered geometry helper,
while hit-testing ran the filtered app-level one and kept only Play/Pause for a
non-Spotify source. Two sources of truth: local files drew Shuffle and Repeat
that looked clickable and silently swallowed the click.

Route rendering and hit-testing through one source-aware list, so a drawn button
is always one that works.

The old filter kept only Play/Pause because, when it was written, nothing else
had a per-source route and a click would have driven the paused librespot instead
of the audible source. That premise is now stale: prev/next, volume, and (as of
this branch) shuffle/repeat all branch on the owning source first. Only Like
still needs a Spotify item id, which no decoded source has.

So a queueable decoded source (Local/Subsonic/YouTube) now offers prev, next,
play/pause, shuffle, repeat and volume, but not Like. Internet radio and the
native queue slot have no finite track list to skip or shuffle, so they keep
play/pause and volume only -- matching the playbar title, which already hides
their shuffle/repeat segments.

The support matrix is a pure function of three booleans rather than of &App: a
LocalPlaybackState needs an Arc<LocalPlayer> holding an open audio device, so it
could not otherwise be tested.
- Repeat One now keeps the context alive when Next is pressed on the last
  track with a song queued. `resume_index_after_queue` returned `None`, which
  `advance_index` means as "clamp, the skip is a no-op" but every resume path
  reads as "context exhausted, tear down"; the two only coincide under Off.
- Repeat All no longer walks an entirely unplayable queue at tick rate. The
  deliberate skip-past self-terminates under Off but Context wraps forever, so
  local play failures are bounded to the tracks that exist.
- Shuffle and repeat no longer reach the real Spotify device from radio or the
  native queue slot, across the four surfaces that must agree: the keyboard,
  the playbar controls, the MPRIS snapshot, and the MPRIS setters.
- The queue slot's playbar keeps the transport it can honour and drops Like,
  which acted on the suspended context rather than the audible track.
- The snapshot describes the queued track instead of the suspended context,
  and macOS Now Playing gained the queue filter its MPRIS twin already had.
- Correct the changelog's repeat cycle order and drop the stale temp/ bug
  report now that the re-gating fix it tracked has landed.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Decoded Local, Subsonic, and YouTube playback now support repeat and shuffle controls, persistence, MPRIS routing, repeat-aware advancement, replay handling, and source-specific playbar rendering. Queue ownership and feature gates were also aligned with these behaviors.

Changes

Decoded playback controls

Layer / File(s) Summary
Repeat state, queue semantics, and persistence
src/infra/queue/mod.rs, src/core/app.rs, src/core/persisted_playback.rs, src/core/user_config.rs, docs/configuration.md, examples/config.example.yml
Adds RepeatMode, repeat-aware queue advancement, decoded app-level shuffle/repeat state, persisted playback fields, and {shuffle}/{repeat} playbar template support.
Source playback routing and replay
src/infra/local/*, src/infra/subsonic/*, src/infra/youtube/*, src/infra/network/mod.rs
Routes repeat and replay events, applies persisted shuffle state, uses repeat-aware navigation, replays current tracks, and tracks failed local tracks.
Ownership, feature gates, metadata, and MPRIS
src/infra/queue/dispatch.rs, src/infra/media_metadata.rs, src/runtime.rs, src/tui/runner.rs, src/infra/player/events.rs
Restricts controls to queueable decoded sources, updates snapshots and MPRIS state, restores playback modes, and consolidates decoded auto-advance.
Playbar controls and mode rendering
src/tui/ui/player.rs
Computes one supported-control set for rendering and hit-testing, and shows shuffle/repeat segments only for applicable sources.
Repository review and lint configuration
.coderabbit.yaml, .luacheckrc
Adds CodeRabbit review configuration and Lua 5.4 validation settings.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TUI
  participant App
  participant SourceRouter
  participant MPRIS
  User->>TUI: toggle shuffle or repeat
  TUI->>App: update decoded playback mode
  App->>SourceRouter: apply mode to active Local/Subsonic/YouTube queue
  SourceRouter->>App: update playback state and snapshot
  App->>MPRIS: publish shuffle and loop status
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes .coderabbit.yaml review-configuration changes that are unrelated to the looping and shuffle feature. Move the CodeRabbit config changes to a separate PR, or remove them from this feature branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement looping for Local Files, Subsonic, and YouTube, matching #367 and extending it with shuffle support.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title uses a valid conventional-commit prefix and clearly matches the shuffle/repeat support added for Local Files, Subsonic, and YouTube.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/repeat-shuffle-other-sources
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feature/repeat-shuffle-other-sources

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
docs/configuration.md (1)

172-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Minor: MD038 markdown-lint spacing in the key table.

The literal leading/trailing spaces inside ` | Shuffle: On ` and ` | Repeat: All ` (used to illustrate the exact rendered spacing) trip markdownlint-cli2's MD038 rule. If this repo enforces markdownlint in CI, this would fail that check.

📝 Suggested fix: avoid literal spaces inside the code span
-| `{shuffle}` | `On` / `Off` | padded to 3 characters. In `playbar_status_source`: pre-composed as ` \| Shuffle: On `, empty for radio/queue slots |
-| `{repeat}` | `Off` / `Track` / `All` | padded to 5 characters. In `playbar_status_source`: pre-composed as ` \| Repeat: All  `, empty for radio/queue slots |
+| `{shuffle}` | `On` / `Off` | padded to 3 characters. In `playbar_status_source`: pre-composed as `\| Shuffle: On` (with its own leading separator and trailing pad), empty for radio/queue slots |
+| `{repeat}` | `Off` / `Track` / `All` | padded to 5 characters. In `playbar_status_source`: pre-composed as `\| Repeat: All` (with its own leading separator and trailing pad), empty for radio/queue slots |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration.md` around lines 172 - 179, Update the `{shuffle}` and
`{repeat}` table descriptions in the configuration documentation to avoid
literal leading or trailing spaces inside inline code spans, while still
documenting the exact rendered spacing using a markdown-safe notation or
surrounding prose. Preserve the existing pre-composed output examples and their
spacing semantics.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/configuration.md`:
- Around line 172-179: Update the `{shuffle}` and `{repeat}` table descriptions
in the configuration documentation to avoid literal leading or trailing spaces
inside inline code spans, while still documenting the exact rendered spacing
using a markdown-safe notation or surrounding prose. Preserve the existing
pre-composed output examples and their spacing semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 570661ff-b934-4f70-a850-aea097d9f260

📥 Commits

Reviewing files that changed from the base of the PR and between 0499e52 and b91ac51.

📒 Files selected for processing (22)
  • .coderabbit.yaml
  • .luacheckrc
  • CHANGELOG.md
  • docs/configuration.md
  • examples/config.example.yml
  • src/core/app.rs
  • src/core/persisted_playback.rs
  • src/core/user_config.rs
  • src/infra/local/dispatch.rs
  • src/infra/local/mod.rs
  • src/infra/media_metadata.rs
  • src/infra/network/mod.rs
  • src/infra/player/events.rs
  • src/infra/queue/dispatch.rs
  • src/infra/queue/mod.rs
  • src/infra/subsonic/dispatch.rs
  • src/infra/subsonic/mod.rs
  • src/infra/youtube/dispatch.rs
  • src/infra/youtube/mod.rs
  • src/runtime.rs
  • src/tui/runner.rs
  • src/tui/ui/player.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Looping for other sources

1 participant