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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
/// One song in a stem playlist, saved with the mix it plays with.
|
||||
struct StemPlaylistItem: Codable, Hashable, Identifiable {
|
||||
@@ -20,7 +21,12 @@ struct StemPlaylist: Codable, Hashable, Identifiable {
|
||||
|
||||
var tracks: [Track] { items.map(\.track) }
|
||||
var mixes: [[String: StemSetting]] { items.map(\.mix) }
|
||||
var thumb: String? { artThumb ?? items.lazy.compactMap(\.track.thumb).first }
|
||||
/// A cover picked from Photos ("local:<file>") falls back to the songs' art if its file is gone
|
||||
/// (an imported list, say).
|
||||
var thumb: String? {
|
||||
if let a = artThumb, !a.hasPrefix(StemPlaylists.localPrefix) || StemPlaylists.localArt(a) != nil { return a }
|
||||
return items.lazy.compactMap(\.track.thumb).first
|
||||
}
|
||||
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
|
||||
}
|
||||
|
||||
@@ -53,7 +59,52 @@ final class StemPlaylists {
|
||||
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
|
||||
}
|
||||
|
||||
func setArt(_ id: String, _ thumb: String?) { edit(id) { $0.artThumb = thumb } }
|
||||
func setArt(_ id: String, _ thumb: String?) {
|
||||
let old = list(id)?.artThumb
|
||||
edit(id) { $0.artThumb = thumb }
|
||||
if old != thumb { Self.removeLocalArt(old) }
|
||||
}
|
||||
|
||||
// ---- covers from Photos -----------------------------------------------------------------
|
||||
|
||||
nonisolated static let localPrefix = "local:"
|
||||
nonisolated static var artDir: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("playlist-art", isDirectory: true)
|
||||
}
|
||||
|
||||
/// The file behind a "local:" thumb, if it's still there.
|
||||
nonisolated static func localArt(_ thumb: String) -> URL? {
|
||||
guard thumb.hasPrefix(localPrefix) else { return nil }
|
||||
let name = String(thumb.dropFirst(localPrefix.count))
|
||||
guard !name.isEmpty, !name.contains("/") else { return nil }
|
||||
let u = artDir.appendingPathComponent(name)
|
||||
return FileManager.default.fileExists(atPath: u.path) ? u : nil
|
||||
}
|
||||
|
||||
nonisolated private static func removeLocalArt(_ thumb: String?) {
|
||||
if let thumb, let u = localArt(thumb) { try? FileManager.default.removeItem(at: u) }
|
||||
}
|
||||
|
||||
/// Saves [data] (any image format Photos hands over) as the list's cover, scaled to 1200 px.
|
||||
/// Each pick gets a new file name so cached art doesn't show the old one.
|
||||
@discardableResult
|
||||
func setPhotoArt(_ id: String, _ data: Data) -> Bool {
|
||||
guard let img = UIImage(data: data) else { return false }
|
||||
let side: CGFloat = 1200
|
||||
let scale = min(1, side / max(img.size.width, img.size.height, 1))
|
||||
let size = CGSize(width: (img.size.width * scale).rounded(), height: (img.size.height * scale).rounded())
|
||||
let fmt = UIGraphicsImageRendererFormat.default(); fmt.scale = 1
|
||||
let jpeg = UIGraphicsImageRenderer(size: size, format: fmt).jpegData(withCompressionQuality: 0.85) { _ in
|
||||
img.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
let name = "\(id)-\(UUID().uuidString.prefix(8).lowercased()).jpg"
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: Self.artDir, withIntermediateDirectories: true)
|
||||
try jpeg.write(to: Self.artDir.appendingPathComponent(name), options: .atomic)
|
||||
} catch { return false }
|
||||
setArt(id, Self.localPrefix + name)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Re-reads every song from the library so renamed, retagged or re-added songs match again.
|
||||
/// Keeps each item's uid and mix, and leaves songs the library can't find as they are.
|
||||
@@ -100,6 +151,7 @@ final class StemPlaylists {
|
||||
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
|
||||
|
||||
func delete(_ id: String) {
|
||||
Self.removeLocalArt(list(id)?.artThumb)
|
||||
lists.removeAll { $0.id == id }
|
||||
save()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user