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

+1 -1
View File
@@ -244,7 +244,7 @@ final class Downloads: @unchecked Sendable {
sources = entry.stems.map { (PlaybackManager.stemKey(entry, $0.file, $0.size, q), stems.fileUrl(entry, $0, q)) }
} else {
guard let part = t.partKey else { throw PlainError("No file on the server") }
sources = [(PlaybackManager.plexKey(t), plex.partUrl(part))]
sources = [(PlaybackManager.plexKey(t), plex.streamUrlNow(t) ?? plex.partUrl(part))]
}
update { m in
m[key]?.keys = sources.map(\.0)
+5 -1
View File
@@ -109,7 +109,7 @@ final class Settings: @unchecked Sendable {
"servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella",
"karaokeVocals", "karaokeSoftDb", "playbackSpeed", "stemsOnly", "noStems", "precacheCount", "hiddenNowPlaying", "presets", "presetOrder", "hiddenPresets", "lockedPreset", "webStations", "lockScreenMix", "acapella", "crossfadeSec", "sweetFades",
]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
@@ -246,6 +246,10 @@ final class Settings: @unchecked Sendable {
var hiddenPresets: String { get { read(\.hiddenPresets, "hiddenPresets", "") } set { write(\.hiddenPresets, "hiddenPresets", newValue, "") } }
/// The id of the preset every song starts with, or empty.
var lockedPreset: String { get { read(\.lockedPreset, "lockedPreset", "") } set { write(\.lockedPreset, "lockedPreset", newValue, "") } }
/// Crossfade length in seconds, 0 is off.
var crossfadeSec: Int { get { read(\.crossfadeSec, "crossfadeSec", 0) } set { write(\.crossfadeSec, "crossfadeSec", min(12, max(0, newValue)), 0) } }
/// Skip the crossfade between songs of the same album.
var sweetFades: Bool { get { read(\.sweetFades, "sweetFades", true) } set { write(\.sweetFades, "sweetFades", newValue, true) } }
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var playbackSpeed: Double {
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 }
+101 -10
View File
@@ -231,6 +231,13 @@ final class StemEngine: @unchecked Sendable {
private var session: TrackSession?
private var next: TrackSession?
/// Crossfade length into [next], in ms (0 = gapless).
private var nextFadeMs = 0
/// The track fading in over the end of [session], and how far through the fade we are.
private var xf: TrackSession?
private var xfLen: Int64 = 0
private var xfDone: Int64 = 0
private var env = [Float](repeating: 0, count: 0)
private var wantPlay = false
private let av = AVAudioEngine()
private var node: AVAudioSourceNode?
@@ -319,11 +326,20 @@ final class StemEngine: @unchecked Sendable {
}
}
/// The track to continue into gaplessly when the current one ends (or nil).
func queueNext(_ s: TrackSession?) {
/// The track to continue into when the current one ends (or nil): gaplessly, or crossfading
/// over the last [fadeMs] of the current track.
func queueNext(_ s: TrackSession?, fadeMs: Int = 0) {
post { [self] in
if next !== s { next?.release() }
next = s
nextFadeMs = s == nil ? 0 : max(0, fadeMs)
}
}
/// Changes the fade into the queued track, if it hasn't started.
func setNextFade(_ fadeMs: Int) {
post { [self] in
if next != nil { nextFadeMs = max(0, fadeMs) }
}
}
@@ -348,6 +364,21 @@ final class StemEngine: @unchecked Sendable {
func seekTo(_ ms: Int64) {
post { [self] in
if let x = xf, let s = session {
xf = nil
if segments.count > 1 {
// The fade hasn't reached the speakers yet, so the seek is in the old track:
// put the new one back as next, from its start, to fade in again later.
x.gen += 1
x.framePos = 0
x.decoders.forEach { $0.seek(gen: x.gen, frame: 0) }
next?.release()
next = x
} else {
s.release()
session = x
}
}
guard let s = session else { return }
let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1))
s.gen += 1
@@ -396,8 +427,10 @@ final class StemEngine: @unchecked Sendable {
private func dropAll() {
session?.release()
next?.release()
xf?.release()
session = nil
next = nil
xf = nil
drainUntil = -1
pauseOutput()
tap?.flush()
@@ -497,9 +530,10 @@ final class StemEngine: @unchecked Sendable {
idle(max(5, Int(ms)))
return
}
let n = mixChunk(s)
startFade(s)
let n = xf != nil ? mixFade(s) : mixChunk(s)
if n == 0 {
if s.finished() { endOfTrack(s) } else { idle(2) }
if xf == nil, let cur = session, cur.finished() { endOfTrack(cur) } else { idle(2) }
return
}
eq.configure(eqParams, sampleRate: s.sampleRate)
@@ -553,20 +587,77 @@ final class StemEngine: @unchecked Sendable {
}
}
private func mixChunk(_ s: TrackSession) -> Int {
if s.finished() { return 0 }
/// Starts the crossfade into [next] once [s] is within the fade length of its end and the next
/// track has buffered. Different sample rates can't share the output, so those stay gapless.
private func startFade(_ s: TrackSession) {
guard xf == nil, nextFadeMs > 0, let nx = next, nx.sampleRate == s.sampleRate, nx.error() == nil else { return }
let dur = s.durationFrames
let len = Int64(nextFadeMs) * Int64(s.sampleRate) / 1000
guard dur > 0, len > 0, s.framePos >= dur - len, !s.finished(), nx.ready(Self.prefill) || nx.finished() else { return }
xf = nx
next = nil
nextFadeMs = 0
xfLen = max(Int64(Self.chunk), min(len, dur - s.framePos))
xfDone = 0
segments.append(Segment(writtenStart: written, token: nx.token, frameStart: nx.framePos))
}
/// One chunk of the crossfade: [s] fading out under [xf] fading in (equal power). When the fade
/// is done, or the old track runs out, the new one becomes the session.
private func mixFade(_ s: TrackSession) -> Int {
guard let x = xf else { return 0 }
let outDone = s.finished()
if x.finished() || outDone && xfDone >= xfLen { return endFade(s, x) }
var n = available(x)
if !outDone { n = min(n, available(s)) }
if n <= 0 { return 0 }
if env.count < n { env = [Float](repeating: 0, count: n) }
out.update(repeating: 0, count: n * 2)
let len = Double(xfLen)
if !outDone {
for f in 0..<n { env[f] = Float(cos(min(1, Double(xfDone + Int64(f)) / len) * .pi / 2)) }
env.withUnsafeBufferPointer { mix(s, n, $0.baseAddress!) }
}
for f in 0..<n { env[f] = Float(sin(min(1, Double(xfDone + Int64(f)) / len) * .pi / 2)) }
env.withUnsafeBufferPointer { mix(x, n, $0.baseAddress!) }
xfDone += Int64(n)
if xfDone >= xfLen { _ = endFade(s, x) }
return n
}
private func endFade(_ s: TrackSession, _ x: TrackSession) -> Int {
s.release()
session = x
xf = nil
return 0
}
/// Frames every live decoder of [s] can give right now, up to a chunk.
private func available(_ s: TrackSession) -> Int {
var n = Self.chunk
for d in s.decoders {
if d.error != nil || d.ring.eos && d.ring.availableFrames == 0 { continue }
n = min(n, d.ring.availableFrames)
}
return n
}
private func mixChunk(_ s: TrackSession) -> Int {
if s.finished() { return 0 }
let n = available(s)
if n <= 0 { return 0 }
out.update(repeating: 0, count: n * 2)
mix(s, n, nil)
return n
}
/// Adds [n] frames of [s]'s stems into [out], each at its mix gain (ramped), times [env] when given.
private func mix(_ s: TrackSession, _ n: Int, _ env: UnsafePointer<Float>?) {
let (gains, version) = shared.with { (_mix, _mixVersion) }
if s.mixVersion != version {
for i in s.names.indices { s.target[i] = gains[s.names[i]] ?? 1 }
s.mixVersion = version
}
out.update(repeating: 0, count: n * 2)
let stride = MeterHistory.maxStems
let block = MeterHistory.block
let blocks = (n + block - 1) / block
@@ -585,8 +676,9 @@ final class StemEngine: @unchecked Sendable {
let end = min(n, (b + 1) * block)
var sum: Float = 0
for f in (b * block)..<end {
let l = tmp[f * 2] * g
let r = tmp[f * 2 + 1] * g
let e = env.map { g * $0[f] } ?? g
let l = tmp[f * 2] * e
let r = tmp[f * 2 + 1] * e
out[f * 2] += l
out[f * 2 + 1] += r
sum += l * l + r * r
@@ -599,7 +691,6 @@ final class StemEngine: @unchecked Sendable {
for b in 0..<blocks { meters.add(s.token, s.framePos + Int64(b * block), p.baseAddress! + b * stride, s.names.count) }
}
s.framePos += Int64(n)
return n
}
private func rampGain(_ buf: UnsafeMutablePointer<Float>, _ n: Int, _ from: Float, _ to: Float) {
+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
+54 -2
View File
@@ -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()
}
+2
View File
@@ -440,6 +440,8 @@ final class JellyfinClient: MediaSource, @unchecked Sendable {
URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token))
}
func streamUrl(_ ratingKey: String, partKey: String) async -> URL? { partUrl(partKey) }
/// True if [url] answers as a Jellyfin server.
func probe() async -> Bool {
guard let u = URL(string: base + "/System/Info/Public") else { return false }
+2
View File
@@ -57,6 +57,8 @@ protocol MediaSource: AnyObject, Sendable {
func timeline(_ track: Track, state: String, positionMs: Int64) async
func artUrl(_ thumb: String?, px: Int) -> URL?
func partUrl(_ partKey: String) -> URL?
/// The URL to stream a track's file from, after any setup the server needs for playback.
func streamUrl(_ ratingKey: String, partKey: String) async -> URL?
/// Music libraries on the server, for the picker in Settings.
func musicSections() async throws -> [MusicSection]
}
+23 -12
View File
@@ -141,18 +141,6 @@ final class PlexClient: @unchecked Sendable {
return (out, total)
}
/// Every track in the library, in pages, shuffled.
func allTracksShuffled() async throws -> [Track] {
let size = 2000
var (out, total) = try await tracksPage(start: 0, size: size)
while out.count < total {
let (page, _) = try await tracksPage(start: out.count, size: size)
if page.isEmpty { break }
out += page
}
return out.shuffled()
}
func randomTracks(_ count: Int) async throws -> [Track] {
let each = count / max(sources.count, 1) + 1
return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count))
@@ -265,6 +253,7 @@ final class PlexClient: @unchecked Sendable {
// ---- URLs -----------------------------------------------------------------------
func artUrl(_ thumb: String?, px: Int) -> URL? {
if let thumb, thumb.hasPrefix(StemPlaylists.localPrefix) { return StemPlaylists.localArt(thumb) }
guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil }
let (s, k) = r
return s.artUrl(k, px: px)
@@ -276,6 +265,26 @@ final class PlexClient: @unchecked Sendable {
return s.partUrl(k)
}
/// [track]'s file, set up for playback on its server (see PlexServer.streamUrl).
func streamUrl(_ track: Track) async -> URL? {
guard let part = track.partKey, let r = route(part) else { return nil }
let (s, k) = r
let rk = route(track.ratingKey)?.1 ?? track.ratingKey
return await s.streamUrl(rk, partKey: k)
}
/// Blocking form of [streamUrl] for worker threads.
func streamUrlNow(_ track: Track) -> URL? {
let box = UrlBox()
let sem = DispatchSemaphore(value: 0)
Task {
box.url = await streamUrl(track)
sem.signal()
}
sem.wait()
return box.url
}
// ---- sign-in helpers --------------------------------------------------------------
/// A Plex client for plex.tv calls and for a server that isn't saved yet.
@@ -333,3 +342,5 @@ final class PlexClient: @unchecked Sendable {
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
}
private final class UrlBox: @unchecked Sendable { var url: URL? }
+26
View File
@@ -317,6 +317,32 @@ final class PlexServer: MediaSource, @unchecked Sendable {
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
}
/// The part's URL the way Plexamp plays it: first a playback decision for this session, then
/// the file fetched under that session and client. A bare part request has no bitrate, so Plex
/// reads it as unlimited ("Bandwidth exceeded: 2147483647 kbps") and answers 500 whenever
/// Remote Access has an upload limit set.
func streamUrl(_ ratingKey: String, partKey: String) async -> URL? {
let session = UUID().uuidString.lowercased()
if signedIn, let u = try? url("/music/:/transcode/universal/decision", [
("path", "/library/metadata/\(ratingKey)"), ("mediaIndex", 0), ("partIndex", 0),
("protocol", "http"), ("directPlay", 1), ("directStream", 1), ("directStreamAudio", 1),
("hasMDE", 1), ("session", session), ("X-Plex-Session-Identifier", session),
]) {
var req = request(u)
req.setValue(session, forHTTPHeaderField: "X-Plex-Session-Identifier")
req.timeoutInterval = 8
// Best effort: the part URL below still carries the session if this fails.
if let (_, resp) = try? await http.session.data(for: req), let code = (resp as? HTTPURLResponse)?.statusCode, code >= 300 {
Log.shared.error("plex", "stream decision for \(ratingKey): HTTP \(code)")
}
}
guard let u = URL(string: base + partKey) else { return nil }
return u.with([
("X-Plex-Token", token), ("X-Plex-Client-Identifier", settings.clientId), ("X-Plex-Product", Self.product),
("X-Plex-Platform", "iOS"), ("X-Plex-Device", model), ("X-Plex-Session-Identifier", session), ("session", session),
])
}
// ---- plex.tv sign-in ------------------------------------------------------------
func createPin() async throws -> PlexPin {
+8 -1
View File
@@ -78,7 +78,14 @@ struct MediaMenu {
if !pl.items.isEmpty {
a.append(SheetAction("Change Cover…", icon: "photo") {
var seen = Set<String>()
var acts: [SheetAction] = [SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) }]
var acts: [SheetAction] = [
SheetAction("Choose from Photos…", icon: "photo.on.rectangle") {
overlays.pickPhoto { data in
if !lists.setPhotoArt(pl.id, data) { overlays.hud("Couldn't use that photo", "exclamationmark.triangle") }
}
},
SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) },
]
for t in pl.tracks {
guard let th = t.thumb, seen.insert(th).inserted, seen.count <= 20 else { continue }
acts.append(SheetAction(t.album, icon: th == pl.artThumb ? "checkmark" : "photo") { lists.setArt(pl.id, th) })
+20
View File
@@ -1,3 +1,4 @@
import PhotosUI
import SwiftUI
struct SheetAction: Identifiable {
@@ -29,6 +30,9 @@ final class Overlays {
var prompt: Prompt?
var hud: Hud?
var addToPlaylist: AddToPlaylist?
/// Set while the Photos picker is up; gets the picked image's data.
var photoPick: ((Data) -> Void)?
var photoShown = false
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
var level = 0
@@ -65,6 +69,10 @@ final class Overlays {
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
}
func pickPhoto(_ run: @escaping (Data) -> Void) {
present { self.photoPick = run; self.photoShown = true }
}
func hud(_ text: String, _ icon: String = "checkmark") {
hudTask?.cancel()
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
@@ -81,6 +89,7 @@ struct OverlayHost: ViewModifier {
let overlays: Overlays
let level: Int
@State private var text = ""
@State private var photo: PhotosPickerItem?
@Environment(\.palette) private var p
private var mine: Bool { overlays.level == level }
@@ -116,6 +125,17 @@ struct OverlayHost: ViewModifier {
if let m = p.message { Text(m) }
}
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
.photosPicker(isPresented: Binding(get: { mine && overlays.photoShown }, set: { overlays.photoShown = $0 }),
selection: $photo, matching: .images)
.onChange(of: photo) { _, item in
guard let item, let run = overlays.photoPick else { return }
photo = nil
overlays.photoPick = nil
Task { @MainActor in
if let data = try? await item.loadTransferable(type: Data.self) { run(data) }
else { overlays.hud("Couldn't load that photo", "exclamationmark.triangle") }
}
}
.sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in
AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent)
}
+1 -1
View File
@@ -36,7 +36,7 @@ enum Tab: String, CaseIterable, Hashable {
}
enum Route: Hashable {
case Artists, Albums, Songs, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
case Artists, Albums, Songs, RecentlyAdded, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
+1
View File
@@ -58,6 +58,7 @@ struct RootView: View {
case .Artists: ArtistsScreen()
case .Albums: AlbumsScreen()
case .Songs: SongsScreen()
case .RecentlyAdded: SongsScreen(recent: true)
case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen()
case .Downloads: DownloadsScreen()
+25 -11
View File
@@ -48,9 +48,9 @@ struct LibraryHome: View {
}
case "Recently Added":
if !added.isEmpty {
SectionTitle(title: "Recently Added")
SectionTitle(title: "Recently Added", action: "See All") { ui.push(.RecentlyAdded) }
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) }
TrackRow(track: t, menu: ui.menu.track(t)) { playRecent(from: t, at: i) }
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
}
}
@@ -82,8 +82,7 @@ struct LibraryHome: View {
SectionTitle(title: "Stations")
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) }
try await g.playback.startSongRadio()
}),
("square.stack", "Random Album Radio", { try await g.playback.startAlbumRadio() }),
]
@@ -152,6 +151,16 @@ struct LibraryHome: View {
}
}
/// Plays the whole Recently Added list (up to 500 songs) from [t], not just the 10 shown here.
private func playRecent(from t: Track, at i: Int) {
let shown = added
Task {
let all = (try? await g.plex.recentlyAddedTracks(count: 500)) ?? []
if let j = all.firstIndex(where: { $0.ratingKey == t.ratingKey }) { g.playback.playTracks(all, start: j) }
else { g.playback.playTracks(shown, start: i) }
}
}
/// Starts a station: its songs play in the order given (albums stay whole).
private func play(_ start: @escaping () async throws -> Void) {
Task {
@@ -542,6 +551,8 @@ struct AlbumsScreen: View {
// ---- songs (paged) --------------------------------------------------------------------------
struct SongsScreen: View {
/// Recently Added: every song, newest first, with no sort menu.
var recent = false
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var tracks: [Track] = []
@@ -555,18 +566,21 @@ struct SongsScreen: View {
private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far.
/// Shuffles the whole library, not just the pages loaded so far: random songs from the server,
/// topped up as they play. Recently Added shuffles what it has loaded.
private func shuffleAll() {
if recent { g.playback.playTracks(tracks, shuffle: true); return }
Task {
do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) }
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
do { try await g.playback.startSongRadio() }
catch { ui.overlays.hud("Couldn't shuffle songs", "exclamationmark.triangle") }
}
}
private var order: String { recent ? "addedAt:desc" : sort.param }
var body: some View {
Page(title: "Songs") {
Page(title: recent ? "Recently Added" : "Songs") {
if !tracks.isEmpty {
if total > 0, !StemsOnly.on, !sort.option.random {
if total > 0, !StemsOnly.on, recent || !sort.option.random {
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
}
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
@@ -584,7 +598,7 @@ struct SongsScreen: View {
if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } }
}
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.toolbar { if !recent { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } }
.task { if tracks.isEmpty { loadMore() } }
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
@@ -595,7 +609,7 @@ struct SongsScreen: View {
private func loadMore() {
if loading || (total >= 0 && offset >= total) { return }
loading = true
let mine = gen, order = sort.param, random = sort.option.random
let mine = gen, order = self.order, random = !recent && sort.option.random
Task {
do {
// Random pages would repeat songs, so it's one shuffled batch.
@@ -31,6 +31,29 @@ private struct ToggleCell: View {
}
}
/// Crossfade length and the same-album exception.
private struct CrossfadeCells: View {
@Environment(\.palette) private var p
private var g: AppGraph { .shared }
var body: some View {
@Bindable var s = g.settings
Picker(selection: Binding(get: { s.crossfadeSec }, set: { s.crossfadeSec = $0; g.playback.crossfadeChanged() })) {
Text("Off").tag(0)
ForEach(1...12, id: \.self) { Text("\($0) s").tag($0) }
} label: {
HStack(spacing: 14) {
SettingsIcon(symbol: "arrow.left.arrow.right", bg: 0xFF9500)
Text("Crossfade").foregroundStyle(p.label)
}
}
.themedRow()
if s.crossfadeSec > 0 {
ToggleCell(title: "Sweet Fades", icon: "rectangle.stack.fill", iconBg: 0xFF9500,
isOn: Binding(get: { s.sweetFades }, set: { s.sweetFades = $0; g.playback.crossfadeChanged() }))
}
}
}
/// How quiet karaoke's Soft vocals are.
private struct SoftVocalsCell: View {
@Environment(\.palette) private var p
@@ -203,6 +226,7 @@ struct SettingsScreen: View {
NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor)
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
CrossfadeCells()
SoftVocalsCell()
} header: { Header(text: "Listening") }
Section {