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

+7
View File
@@ -20,3 +20,10 @@
2026-10-31 iOS: version is now 1.0 RC 1 (build 21). Bundle version 1.0, label in StemifyVersionLabel, IPA stemify-1.0-rc1.ipa, tag ios-v1.0-rc1. 2026-10-31 iOS: version is now 1.0 RC 1 (build 21). Bundle version 1.0, label in StemifyVersionLabel, IPA stemify-1.0-rc1.ipa, tag ios-v1.0-rc1.
2026-10-31 iOS: battery and heat. Waveform redraws 4/s instead of 120, meters and playing bars capped at 30 fps. Engine sleeps until its buffer is half drained (120 ms foreground, 600 ms with no screen), 2 s idle while paused. Decoders wake on a low-water mark and run at userInitiated. stemd polling stops in the background unless CarPlay is connected. Widget reloads settle for 0.6 s. 2026-10-31 iOS: battery and heat. Waveform redraws 4/s instead of 120, meters and playing bars capped at 30 fps. Engine sleeps until its buffer is half drained (120 ms foreground, 600 ms with no screen), 2 s idle while paused. Decoders wake on a low-water mark and run at userInitiated. stemd polling stops in the background unless CarPlay is connected. Widget reloads settle for 0.6 s.
2026-10-31 web: player redraws the waveform only when the playhead moves a bar (200 ms tick instead of rAF), caches the envelope, and stops polling while the tab is hidden. 2026-10-31 web: player redraws the waveform only when the playhead moves a bar (200 ms tick instead of rAF), caches the envelope, and stops polling while the tab is hidden.
2026-11-06 iOS: version is now 1.0 RC 2 (build 22). IPA stemify-1.0-rc2.ipa, tag ios-v1.0-rc2.
2026-11-06 iOS: plain Plex songs play when Remote Access has an upload cap. Streams now ask Plex for a playback decision first and fetch the part with that session, like Plexamp, instead of a bare part URL that Plex rejected with a 500.
2026-11-06 iOS: shuffle buttons (Songs, Library Radio) play server-side random batches of 100 and top up as the queue runs low, instead of loading the whole library and timing out.
2026-11-06 iOS: Random Album Radio works like Plexamp. It shuffles the album list once, keeps about 8 albums queued ahead in that order, plays each album in track order, and tops up when 3 are left. The station survives a restart.
2026-11-06 iOS: Recently Added has See All, a full scrolling list sorted by date added. Tapping a song on the Library page plays from it through the last 500 added songs.
2026-11-06 iOS: stem playlist covers can come from Photos (Change Cover > Choose from Photos). Stored on the phone, 1200 px JPEG.
2026-11-06 iOS: crossfade, Off or 1-12 s in Settings > Listening. Equal-power fade, Sweet Fades skips it between songs of the same album. Seeking during a fade finishes it.
+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)) } sources = entry.stems.map { (PlaybackManager.stemKey(entry, $0.file, $0.size, q), stems.fileUrl(entry, $0, q)) }
} else { } else {
guard let part = t.partKey else { throw PlainError("No file on the server") } 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 update { m in
m[key]?.keys = sources.map(\.0) 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", "servers", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemUrl", "stemToken", "plexAccountToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout", "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 { 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, "") } } var hiddenPresets: String { get { read(\.hiddenPresets, "hiddenPresets", "") } set { write(\.hiddenPresets, "hiddenPresets", newValue, "") } }
/// The id of the preset every song starts with, or empty. /// The id of the preset every song starts with, or empty.
var lockedPreset: String { get { read(\.lockedPreset, "lockedPreset", "") } set { write(\.lockedPreset, "lockedPreset", newValue, "") } } 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 keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var playbackSpeed: Double { var playbackSpeed: Double {
get { access(keyPath: \.playbackSpeed); lock.lock(); defer { lock.unlock() }; return (store["playbackSpeed"] as? NSNumber)?.doubleValue ?? 1 } 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 session: TrackSession?
private var next: 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 var wantPlay = false
private let av = AVAudioEngine() private let av = AVAudioEngine()
private var node: AVAudioSourceNode? 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). /// The track to continue into when the current one ends (or nil): gaplessly, or crossfading
func queueNext(_ s: TrackSession?) { /// over the last [fadeMs] of the current track.
func queueNext(_ s: TrackSession?, fadeMs: Int = 0) {
post { [self] in post { [self] in
if next !== s { next?.release() } if next !== s { next?.release() }
next = s 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) { func seekTo(_ ms: Int64) {
post { [self] in 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 } guard let s = session else { return }
let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1)) let frame = min(max(ms * Int64(s.sampleRate) / 1000, 0), max(0, s.durationFrames - 1))
s.gen += 1 s.gen += 1
@@ -396,8 +427,10 @@ final class StemEngine: @unchecked Sendable {
private func dropAll() { private func dropAll() {
session?.release() session?.release()
next?.release() next?.release()
xf?.release()
session = nil session = nil
next = nil next = nil
xf = nil
drainUntil = -1 drainUntil = -1
pauseOutput() pauseOutput()
tap?.flush() tap?.flush()
@@ -497,9 +530,10 @@ final class StemEngine: @unchecked Sendable {
idle(max(5, Int(ms))) idle(max(5, Int(ms)))
return return
} }
let n = mixChunk(s) startFade(s)
let n = xf != nil ? mixFade(s) : mixChunk(s)
if n == 0 { 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 return
} }
eq.configure(eqParams, sampleRate: s.sampleRate) eq.configure(eqParams, sampleRate: s.sampleRate)
@@ -553,20 +587,77 @@ final class StemEngine: @unchecked Sendable {
} }
} }
private func mixChunk(_ s: TrackSession) -> Int { /// Starts the crossfade into [next] once [s] is within the fade length of its end and the next
if s.finished() { return 0 } /// 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 var n = Self.chunk
for d in s.decoders { for d in s.decoders {
if d.error != nil || d.ring.eos && d.ring.availableFrames == 0 { continue } if d.error != nil || d.ring.eos && d.ring.availableFrames == 0 { continue }
n = min(n, d.ring.availableFrames) 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 } 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) } let (gains, version) = shared.with { (_mix, _mixVersion) }
if s.mixVersion != version { if s.mixVersion != version {
for i in s.names.indices { s.target[i] = gains[s.names[i]] ?? 1 } for i in s.names.indices { s.target[i] = gains[s.names[i]] ?? 1 }
s.mixVersion = version s.mixVersion = version
} }
out.update(repeating: 0, count: n * 2)
let stride = MeterHistory.maxStems let stride = MeterHistory.maxStems
let block = MeterHistory.block let block = MeterHistory.block
let blocks = (n + block - 1) / block let blocks = (n + block - 1) / block
@@ -585,8 +676,9 @@ final class StemEngine: @unchecked Sendable {
let end = min(n, (b + 1) * block) let end = min(n, (b + 1) * block)
var sum: Float = 0 var sum: Float = 0
for f in (b * block)..<end { for f in (b * block)..<end {
let l = tmp[f * 2] * g let e = env.map { g * $0[f] } ?? g
let r = tmp[f * 2 + 1] * g let l = tmp[f * 2] * e
let r = tmp[f * 2 + 1] * e
out[f * 2] += l out[f * 2] += l
out[f * 2 + 1] += r out[f * 2 + 1] += r
sum += l * l + r * 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) } 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) s.framePos += Int64(n)
return n
} }
private func rampGain(_ buf: UnsafeMutablePointer<Float>, _ n: Int, _ from: Float, _ to: Float) { 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 positionMs: Int64 = 0
var repeatMode: RepeatMode = .Off var repeatMode: RepeatMode = .Off
var mix: [String: StemSetting] = [:] 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.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 { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) 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 positionMs = (try? c.decode(Int64.self, forKey: .positionMs)) ?? 0
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off
mix = (try? c.decode([String: StemSetting].self, forKey: .mix)) ?? [:] 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() { private func changed() {
listeners.values.forEach { $0() } listeners.values.forEach { $0() }
topUpRadio() topUpStation()
} }
/// Album radio: every album in the library in random order, each played whole and in track order. /// Endless queues that top themselves up as they play.
/// Only the next album is fetched, once the queue is nearly played out. /// Album radio (Plexamp's Random Album Radio): every album in the library in one shuffled order,
@ObservationIgnored private var radioDeck: [Album]? /// each played whole and in track order. The next several albums sit in the queue up front and
@ObservationIgnored private var radioBusy = false /// 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 { 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] = [] var first: [Track] = []
while first.isEmpty, !deck.isEmpty { first = (try? await plex.albumTracks(deck.removeLast().ratingKey)) ?? [] } while first.isEmpty, !deck.isEmpty { first = await albumTracks([deck.removeFirst()]) }
if first.isEmpty { return } if first.isEmpty { throw RadioEmpty() }
playTracks(first) playTracks(first)
radioDeck = deck setStation(.albums(deck))
topUpStation()
} }
private func topUpRadio() { func startSongRadio() async throws {
guard radioDeck != nil, !radioBusy, queue.count - index < 3 else { return } let first = try await plex.randomTracks(Self.radioSongsBatch)
radioBusy = true if first.isEmpty { throw RadioEmpty() }
Task { @MainActor in playTracks(first)
defer { radioBusy = false } setStation(.songs)
while var deck = radioDeck, !deck.isEmpty { }
let album = deck.removeLast()
radioDeck = deck struct RadioEmpty: LocalizedError { var errorDescription: String? { "No music found in your library" } }
if let t = try? await plex.albumTracks(album.ratingKey), !t.isEmpty, radioDeck != nil {
addToQueue(t) private func topUpStation() {
return 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) { func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
if tracks.isEmpty { return } if tracks.isEmpty { return }
radioDeck = nil setStation(nil)
let items = items(tracks, mixes) let items = items(tracks, mixes)
if doShuffle { if doShuffle {
unshuffled = items unshuffled = items
@@ -332,6 +389,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
} }
func clearUpNext() { func clearUpNext() {
setStation(nil)
guard index >= 0 else { return } guard index >= 0 else { return }
queue = Array(queue.prefix(index + 1)) queue = Array(queue.prefix(index + 1))
unshuffled = nil unshuffled = nil
@@ -454,6 +512,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
reportTimeline("stopped") reportTimeline("stopped")
currentToken = -1 currentToken = -1
queued = [:] queued = [:]
setStation(nil)
queue = [] queue = []
index = -1 index = -1
current = nil current = nil
@@ -649,15 +708,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
} }
private func openDetached(_ track: Track, _ startMs: Int64) async throws -> (TrackSession, StemEntry?) { 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 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. /// 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) let entry = stems.find(track)
var sources: [(name: String, key: String, url: URL?, hint: String)] = [] var sources: [(name: String, key: String, url: URL?, hint: String)] = []
if let entry { if let entry {
@@ -671,7 +733,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
} else { } else {
guard let part = track.partKey else { throw PlainError("Plex has no file for this track") } 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 ?? "" 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") } if sources.isEmpty { throw PlainError("No stems listed for this track") }
let decoders = sources.map { src -> StemDecoder in let decoders = sources.map { src -> StemDecoder in
@@ -723,7 +785,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
return return
} }
self.queued[s.token] = (item.uid, entry) 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) 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() { private func requeueNext() {
if currentToken >= 0 { prepareNext() } if currentToken >= 0 { prepareNext() }
} }
@@ -922,7 +998,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
guard let self, !Task.isCancelled else { return } guard let self, !Task.isCancelled else { return }
let snap = SavedQueue(items: self.queue, unshuffled: self.unshuffled, index: self.index, 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 let file = self.queueFile
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
guard let data = try? JSONEncoder().encode(snap) else { return } 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. /// Saves right away, for when the app is about to be suspended.
func flushSave() { func flushSave() {
saveTask?.cancel() 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) } 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 shuffle = saved.unshuffled != nil
repeatMode = saved.repeatMode repeatMode = saved.repeatMode
mix = saved.mix mix = saved.mix
station = saved.station
nextUid = (saved.items.map(\.uid).max() ?? 0) + 1 nextUid = (saved.items.map(\.uid).max() ?? 0) + 1
let i = min(max(saved.index, -1), saved.items.count - 1) let i = min(max(saved.index, -1), saved.items.count - 1)
index = i index = i
+54 -2
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import UIKit
/// One song in a stem playlist, saved with the mix it plays with. /// One song in a stem playlist, saved with the mix it plays with.
struct StemPlaylistItem: Codable, Hashable, Identifiable { struct StemPlaylistItem: Codable, Hashable, Identifiable {
@@ -20,7 +21,12 @@ struct StemPlaylist: Codable, Hashable, Identifiable {
var tracks: [Track] { items.map(\.track) } var tracks: [Track] { items.map(\.track) }
var mixes: [[String: StemSetting]] { items.map(\.mix) } 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 } } 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)) } } 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. /// 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. /// 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 rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
func delete(_ id: String) { func delete(_ id: String) {
Self.removeLocalArt(list(id)?.artThumb)
lists.removeAll { $0.id == id } lists.removeAll { $0.id == id }
save() 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)) 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. /// True if [url] answers as a Jellyfin server.
func probe() async -> Bool { func probe() async -> Bool {
guard let u = URL(string: base + "/System/Info/Public") else { return false } 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 timeline(_ track: Track, state: String, positionMs: Int64) async
func artUrl(_ thumb: String?, px: Int) -> URL? func artUrl(_ thumb: String?, px: Int) -> URL?
func partUrl(_ partKey: String) -> 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. /// Music libraries on the server, for the picker in Settings.
func musicSections() async throws -> [MusicSection] func musicSections() async throws -> [MusicSection]
} }
+23 -12
View File
@@ -141,18 +141,6 @@ final class PlexClient: @unchecked Sendable {
return (out, total) 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] { func randomTracks(_ count: Int) async throws -> [Track] {
let each = count / max(sources.count, 1) + 1 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)) 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 ----------------------------------------------------------------------- // ---- URLs -----------------------------------------------------------------------
func artUrl(_ thumb: String?, px: Int) -> URL? { 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 } guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil }
let (s, k) = r let (s, k) = r
return s.artUrl(k, px: px) return s.artUrl(k, px: px)
@@ -276,6 +265,26 @@ final class PlexClient: @unchecked Sendable {
return s.partUrl(k) 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 -------------------------------------------------------------- // ---- sign-in helpers --------------------------------------------------------------
/// A Plex client for plex.tv calls and for a server that isn't saved yet. /// 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) } 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)) 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 ------------------------------------------------------------ // ---- plex.tv sign-in ------------------------------------------------------------
func createPin() async throws -> PlexPin { func createPin() async throws -> PlexPin {
+8 -1
View File
@@ -78,7 +78,14 @@ struct MediaMenu {
if !pl.items.isEmpty { if !pl.items.isEmpty {
a.append(SheetAction("Change Cover…", icon: "photo") { a.append(SheetAction("Change Cover…", icon: "photo") {
var seen = Set<String>() 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 { for t in pl.tracks {
guard let th = t.thumb, seen.insert(th).inserted, seen.count <= 20 else { continue } 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) }) 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 import SwiftUI
struct SheetAction: Identifiable { struct SheetAction: Identifiable {
@@ -29,6 +30,9 @@ final class Overlays {
var prompt: Prompt? var prompt: Prompt?
var hud: Hud? var hud: Hud?
var addToPlaylist: AddToPlaylist? 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. /// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
var level = 0 var level = 0
@@ -65,6 +69,10 @@ final class Overlays {
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) } 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") { func hud(_ text: String, _ icon: String = "checkmark") {
hudTask?.cancel() hudTask?.cancel()
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) } withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
@@ -81,6 +89,7 @@ struct OverlayHost: ViewModifier {
let overlays: Overlays let overlays: Overlays
let level: Int let level: Int
@State private var text = "" @State private var text = ""
@State private var photo: PhotosPickerItem?
@Environment(\.palette) private var p @Environment(\.palette) private var p
private var mine: Bool { overlays.level == level } private var mine: Bool { overlays.level == level }
@@ -116,6 +125,17 @@ struct OverlayHost: ViewModifier {
if let m = p.message { Text(m) } if let m = p.message { Text(m) }
} }
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" } .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 .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) 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 { 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 Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) 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 .Artists: ArtistsScreen()
case .Albums: AlbumsScreen() case .Albums: AlbumsScreen()
case .Songs: SongsScreen() case .Songs: SongsScreen()
case .RecentlyAdded: SongsScreen(recent: true)
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen() case .Genres: GenresScreen()
case .Downloads: DownloadsScreen() case .Downloads: DownloadsScreen()
+25 -11
View File
@@ -48,9 +48,9 @@ struct LibraryHome: View {
} }
case "Recently Added": case "Recently Added":
if !added.isEmpty { 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 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) .listRow(inset: MARGIN + 60, line: i < added.count - 1)
} }
} }
@@ -82,8 +82,7 @@ struct LibraryHome: View {
SectionTitle(title: "Stations") SectionTitle(title: "Stations")
let stations: [(String, String, () async throws -> Void)] = [ let stations: [(String, String, () async throws -> Void)] = [
("dot.radiowaves.left.and.right", "Library Radio", { ("dot.radiowaves.left.and.right", "Library Radio", {
let list = try await g.plex.allTracksShuffled() try await g.playback.startSongRadio()
if !list.isEmpty { g.playback.playTracks(list) }
}), }),
("square.stack", "Random Album Radio", { try await g.playback.startAlbumRadio() }), ("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). /// Starts a station: its songs play in the order given (albums stay whole).
private func play(_ start: @escaping () async throws -> Void) { private func play(_ start: @escaping () async throws -> Void) {
Task { Task {
@@ -542,6 +551,8 @@ struct AlbumsScreen: View {
// ---- songs (paged) -------------------------------------------------------------------------- // ---- songs (paged) --------------------------------------------------------------------------
struct SongsScreen: View { struct SongsScreen: View {
/// Recently Added: every song, newest first, with no sort menu.
var recent = false
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@Environment(\.palette) private var p @Environment(\.palette) private var p
@State private var tracks: [Track] = [] @State private var tracks: [Track] = []
@@ -555,18 +566,21 @@ struct SongsScreen: View {
private let sort = SortState("songs", SONG_SORTS) private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared } 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() { private func shuffleAll() {
if recent { g.playback.playTracks(tracks, shuffle: true); return }
Task { Task {
do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) } do { try await g.playback.startSongRadio() }
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") } catch { ui.overlays.hud("Couldn't shuffle songs", "exclamationmark.triangle") }
} }
} }
private var order: String { recent ? "addedAt:desc" : sort.param }
var body: some View { var body: some View {
Page(title: "Songs") { Page(title: recent ? "Recently Added" : "Songs") {
if !tracks.isEmpty { 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) Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
} }
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) }, PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
@@ -584,7 +598,7 @@ struct SongsScreen: View {
if loading { LoadingRow() } if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } } 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() } } .task { if tracks.isEmpty { loadMore() } }
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") { .onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
@@ -595,7 +609,7 @@ struct SongsScreen: View {
private func loadMore() { private func loadMore() {
if loading || (total >= 0 && offset >= total) { return } if loading || (total >= 0 && offset >= total) { return }
loading = true loading = true
let mine = gen, order = sort.param, random = sort.option.random let mine = gen, order = self.order, random = !recent && sort.option.random
Task { Task {
do { do {
// Random pages would repeat songs, so it's one shuffled batch. // 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. /// How quiet karaoke's Soft vocals are.
private struct SoftVocalsCell: View { private struct SoftVocalsCell: View {
@Environment(\.palette) private var p @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 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) 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) ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
CrossfadeCells()
SoftVocalsCell() SoftVocalsCell()
} header: { Header(text: "Listening") } } header: { Header(text: "Listening") }
Section { Section {
+2 -2
View File
@@ -8,8 +8,8 @@ settings:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
# iOS needs a plain number for the bundle version; the label is what the app and releases show. # iOS needs a plain number for the bundle version; the label is what the app and releases show.
MARKETING_VERSION: "1.0" MARKETING_VERSION: "1.0"
VERSION_LABEL: "1.0 RC 1" VERSION_LABEL: "1.0 RC 2"
CURRENT_PROJECT_VERSION: "21" CURRENT_PROJECT_VERSION: "22"
targets: targets:
Stemify: Stemify:
type: application type: application