iOS 1.0 RC 2: Plex streams under upload caps, shuffle and album radio stations, Recently Added list, photo covers, crossfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-06 08:40:37 -08:00
1 parent c0e33b5e3e
commit 2fb8040388
17 files changed
+412 -74

No files matched your search

+110 -33
View File
@@ -64,13 +64,14 @@ private struct SavedQueue: Codable {
var positionMs: Int64 = 0
var repeatMode: RepeatMode = .Off
var mix: [String: StemSetting] = [:]
var station: PlaybackManager.Station?
init(items: [QueueItem], unshuffled: [QueueItem]?, index: Int, positionMs: Int64, repeatMode: RepeatMode, mix: [String: StemSetting]) {
init(items: [QueueItem], unshuffled: [QueueItem]?, index: Int, positionMs: Int64, repeatMode: RepeatMode, mix: [String: StemSetting], station: PlaybackManager.Station?) {
self.items = items; self.unshuffled = unshuffled; self.index = index
self.positionMs = positionMs; self.repeatMode = repeatMode; self.mix = mix
self.positionMs = positionMs; self.repeatMode = repeatMode; self.mix = mix; self.station = station
}
enum CodingKeys: String, CodingKey { case items, unshuffled, index, positionMs, repeatMode = "repeat", mix }
enum CodingKeys: String, CodingKey { case items, unshuffled, index, positionMs, repeatMode = "repeat", mix, station }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -80,6 +81,7 @@ private struct SavedQueue: Codable {
positionMs = (try? c.decode(Int64.self, forKey: .positionMs)) ?? 0
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off
mix = (try? c.decode([String: StemSetting].self, forKey: .mix)) ?? [:]
station = try? c.decode(PlaybackManager.Station.self, forKey: .station)
}
}
@@ -203,37 +205,92 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
private func changed() {
listeners.values.forEach { $0() }
topUpRadio()
topUpStation()
}
/// 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
/// Endless queues that top themselves up as they play.
/// Album radio (Plexamp's Random Album Radio): every album in the library in one shuffled order,
/// each played whole and in track order. The next several albums sit in the queue up front and
/// more are added, in deck order, as they play out.
/// Song radio: random songs from the whole library, fetched from the server a batch at a time
/// (Library Radio and Songs > Shuffle), so nothing has to load the whole library first.
enum Station: Codable, Equatable { case albums([String]), songs }
@ObservationIgnored private var station: Station?
@ObservationIgnored private var stationGen = 0
@ObservationIgnored private var stationBusy = false
private static let radioAlbumsAhead = 8
private static let radioAlbumsLow = 3
private static let radioSongsBatch = 100
private static let radioSongsLow = 25
private func setStation(_ s: Station?) { station = s; stationGen += 1; stationBusy = false }
private func albumDeck() async throws -> [String] {
var deck = (try? await plex.albums().map(\.ratingKey)) ?? []
if deck.isEmpty { deck = try await plex.randomAlbums(500).map(\.ratingKey) }
return deck.shuffled()
}
/// Tracks of [keys] in that order, fetched in parallel. Albums that fail or are empty are skipped.
private func albumTracks(_ keys: [String]) async -> [Track] {
let plex = plex
let lists = await withTaskGroup(of: (Int, [Track]).self) { group in
for (i, k) in keys.enumerated() { group.addTask { (i, (try? await plex.albumTracks(k)) ?? []) } }
var out = [[Track]](repeating: [], count: keys.count)
for await (i, t) in group { out[i] = t }
return out
}
return lists.flatMap { $0 }
}
func startAlbumRadio() async throws {
var deck = try await plex.albums().shuffled()
var deck = try await albumDeck()
// The first album alone so music starts right away, then the rest of the look-ahead.
var first: [Track] = []
while first.isEmpty, !deck.isEmpty { first = (try? await plex.albumTracks(deck.removeLast().ratingKey)) ?? [] }
if first.isEmpty { return }
while first.isEmpty, !deck.isEmpty { first = await albumTracks([deck.removeFirst()]) }
if first.isEmpty { throw RadioEmpty() }
playTracks(first)
radioDeck = deck
setStation(.albums(deck))
topUpStation()
}
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
}
func startSongRadio() async throws {
let first = try await plex.randomTracks(Self.radioSongsBatch)
if first.isEmpty { throw RadioEmpty() }
playTracks(first)
setStation(.songs)
}
struct RadioEmpty: LocalizedError { var errorDescription: String? { "No music found in your library" } }
private func topUpStation() {
guard let st = station, !stationBusy, index >= 0, index < queue.count else { return }
let gen = stationGen
switch st {
case .albums(let saved):
let ahead = Set(queue[index...].map { $0.track.albumKey ?? $0.track.album }).count
guard ahead <= Self.radioAlbumsLow else { return }
stationBusy = true
Task { @MainActor in
defer { if gen == stationGen { stationBusy = false } }
var deck = saved
if deck.isEmpty { deck = (try? await albumDeck()) ?? [] }
let take = Array(deck.prefix(Self.radioAlbumsAhead + 1 - ahead))
deck.removeFirst(take.count)
let tracks = await albumTracks(take)
guard gen == stationGen else { return }
station = .albums(deck)
addToQueue(tracks)
}
case .songs:
guard queue.count - index <= Self.radioSongsLow else { return }
stationBusy = true
Task { @MainActor in
defer { if gen == stationGen { stationBusy = false } }
let have = Set(queue.map(\.track.ratingKey))
guard let more = try? await plex.randomTracks(Self.radioSongsBatch), gen == stationGen else { return }
addToQueue(more.filter { !have.contains($0.ratingKey) })
}
if radioDeck != nil, let all = try? await plex.albums(), !all.isEmpty { radioDeck = all.shuffled() }
}
}
@@ -263,7 +320,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
setStation(nil)
let items = items(tracks, mixes)
if doShuffle {
unshuffled = items
@@ -332,6 +389,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
func clearUpNext() {
setStation(nil)
guard index >= 0 else { return }
queue = Array(queue.prefix(index + 1))
unshuffled = nil
@@ -454,6 +512,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
reportTimeline("stopped")
currentToken = -1
queued = [:]
setStation(nil)
queue = []
index = -1
current = nil
@@ -649,15 +708,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
private func openDetached(_ track: Track, _ startMs: Int64) async throws -> (TrackSession, StemEntry?) {
try await withCheckedThrowingContinuation { cont in
// Plain Plex files need a playback session first, or a Remote Access upload limit fails them.
var stream: URL?
if stems.find(track) == nil, !cache.isComplete(Self.plexKey(track)) { stream = await plex.streamUrl(track) }
return try await withCheckedThrowingContinuation { [stream] cont in
DispatchQueue.global(qos: .userInitiated).async { [self] in
cont.resume(with: Result { try open(track, startMs) })
cont.resume(with: Result { try open(track, startMs, stream: stream) })
}
}
}
/// Opens every stem (or the original file) and waits for the decoders to know their format.
private func open(_ track: Track, _ startMs: Int64) throws -> (TrackSession, StemEntry?) {
private func open(_ track: Track, _ startMs: Int64, stream: URL? = nil) throws -> (TrackSession, StemEntry?) {
let entry = stems.find(track)
var sources: [(name: String, key: String, url: URL?, hint: String)] = []
if let entry {
@@ -671,7 +733,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
} else {
guard let part = track.partKey else { throw PlainError("Plex has no file for this track") }
let hint = track.partFile.map { ($0 as NSString).pathExtension } ?? track.codec ?? ""
sources.append(("mix", Self.plexKey(track), plex.partUrl(part), hint))
sources.append(("mix", Self.plexKey(track), stream ?? plex.partUrl(part), hint))
}
if sources.isEmpty { throw PlainError("No stems listed for this track") }
let decoders = sources.map { src -> StemDecoder in
@@ -723,7 +785,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
return
}
self.queued[s.token] = (item.uid, entry)
self.engine.queueNext(s)
self.engine.queueNext(s, fadeMs: self.fadeMs(into: item.track))
await self.precacheAhead(from: i)
}
}
@@ -746,6 +808,20 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
}
/// How long to crossfade from the current track into `next`, in ms.
private func fadeMs(into next: Track) -> Int {
let sec = settings.crossfadeSec
guard sec > 0 else { return 0 }
if settings.sweetFades, let a = current?.track.albumKey, a == next.albumKey { return 0 }
return sec * 1000
}
/// Applies a changed crossfade setting to the queued track.
func crossfadeChanged() {
guard let i = upcomingIndex() else { return }
engine.setNextFade(fadeMs(into: queue[i].track))
}
private func requeueNext() {
if currentToken >= 0 { prepareNext() }
}
@@ -922,7 +998,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
try? await Task.sleep(nanoseconds: 500_000_000)
guard let self, !Task.isCancelled else { return }
let snap = SavedQueue(items: self.queue, unshuffled: self.unshuffled, index: self.index,
positionMs: self.positionMs(), repeatMode: self.repeatMode, mix: self.mix)
positionMs: self.positionMs(), repeatMode: self.repeatMode, mix: self.mix, station: self.station)
let file = self.queueFile
DispatchQueue.global(qos: .utility).async {
guard let data = try? JSONEncoder().encode(snap) else { return }
@@ -934,7 +1010,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
/// Saves right away, for when the app is about to be suspended.
func flushSave() {
saveTask?.cancel()
let snap = SavedQueue(items: queue, unshuffled: unshuffled, index: index, positionMs: positionMs(), repeatMode: repeatMode, mix: mix)
let snap = SavedQueue(items: queue, unshuffled: unshuffled, index: index, positionMs: positionMs(), repeatMode: repeatMode, mix: mix, station: station)
if let data = try? JSONEncoder().encode(snap) { try? data.write(to: queueFile, options: .atomic) }
}
@@ -945,6 +1021,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
shuffle = saved.unshuffled != nil
repeatMode = saved.repeatMode
mix = saved.mix
station = saved.station
nextUid = (saved.items.map(\.uid).max() ?? 0) + 1
let i = min(max(saved.index, -1), saved.items.count - 1)
index = i