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:
1 parent
c0e33b5e3e
commit
2fb8040388
17 files changed
+412
-74
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user