From 3e832e3125986fa5299de7b304f337bddd8ee22b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Oct 2026 15:56:02 -0700 Subject: [PATCH] iOS: Random Album Radio plays whole albums in random order, loading each next album lazily Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + ios/Stemify/Playback/PlaybackManager.swift | 37 ++++++++++++++++++++- ios/Stemify/UI/Screens/LibraryScreens.swift | 20 +++++------ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3253878..d697ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ 2026-10-28 iOS: stem playlists refresh their track metadata from Plex on each library load. Playlist covers can be changed from the playlist menu. 2026-10-28 iOS: Downloads 3-dots menu gets View Queue (waiting, downloading, failed, done). 2026-10-28 iOS: Library screen can be reordered (Reorder button). Library Radio and Random Album Radio shuffle the whole library. +2026-10-28 iOS: Random Album Radio plays whole albums in track order, albums in random order across the library, loading the next album as one finishes. diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index a0c574c..8b5a4e9 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -201,7 +201,41 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { messageListeners[id] = f return id } - private func changed() { listeners.values.forEach { $0() } } + private func changed() { + listeners.values.forEach { $0() } + topUpRadio() + } + + /// Album radio: every album in the library in random order, each played whole and in track order. + /// Only the next album is fetched, once the queue is nearly played out. + @ObservationIgnored private var radioDeck: [Album]? + @ObservationIgnored private var radioBusy = false + + func startAlbumRadio() async throws { + var deck = try await plex.albums().shuffled() + var first: [Track] = [] + while first.isEmpty, !deck.isEmpty { first = (try? await plex.albumTracks(deck.removeLast().ratingKey)) ?? [] } + if first.isEmpty { return } + playTracks(first) + radioDeck = deck + } + + private func topUpRadio() { + guard radioDeck != nil, !radioBusy, queue.count - index < 3 else { return } + radioBusy = true + Task { @MainActor in + defer { radioBusy = false } + while var deck = radioDeck, !deck.isEmpty { + let album = deck.removeLast() + radioDeck = deck + if let t = try? await plex.albumTracks(album.ratingKey), !t.isEmpty, radioDeck != nil { + addToQueue(t) + return + } + } + if radioDeck != nil, let all = try? await plex.albums(), !all.isEmpty { radioDeck = all.shuffled() } + } + } func positionMs() -> Int64 { let snap = engine.snapshot @@ -229,6 +263,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) { if tracks.isEmpty { return } + radioDeck = nil let items = items(tracks, mixes) if doShuffle { unshuffled = items diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index af910f4..90346c0 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -79,14 +79,12 @@ struct LibraryHome: View { } if ready { SectionTitle(title: "Stations") - let stations: [(String, String, () async throws -> [Track])] = [ - ("dot.radiowaves.left.and.right", "Library Radio", { try await g.plex.allTracksShuffled() }), - ("square.stack", "Random Album Radio", { - // Whole albums in random order, across the entire library. - var out: [Track] = [] - for a in try await g.plex.albums().shuffled() { out += try await g.plex.albumTracks(a.ratingKey) } - return out + let stations: [(String, String, () async throws -> Void)] = [ + ("dot.radiowaves.left.and.right", "Library Radio", { + let list = try await g.plex.allTracksShuffled() + if !list.isEmpty { g.playback.playTracks(list) } }), + ("square.stack", "Random Album Radio", { try await g.playback.startAlbumRadio() }), ] let shown = ordered(stations) { $0.1 } ForEach(shown, id: \.1) { st in @@ -126,12 +124,10 @@ struct LibraryHome: View { } /// Starts a station: its songs play in the order given (albums stay whole). - private func play(_ load: @escaping () async throws -> [Track]) { + private func play(_ start: @escaping () async throws -> Void) { Task { - do { - let list = try await load() - if list.isEmpty { ui.overlays.hud("Nothing to play", "music.note") } else { g.playback.playTracks(list) } - } catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") } + do { try await start() } + catch { ui.overlays.hud("Couldn't start station", "exclamationmark.triangle") } } } }