From 2fb80403888ce66087afb1ce35afa46f2198972f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 6 Nov 2026 08:40:37 -0800 Subject: [PATCH] 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 --- CHANGELOG.md | 7 + ios/Stemify/Cache/Downloads.swift | 2 +- ios/Stemify/Core/Settings.swift | 6 +- ios/Stemify/Engine/StemEngine.swift | 111 ++++++++++++-- ios/Stemify/Playback/PlaybackManager.swift | 143 ++++++++++++++----- ios/Stemify/Playback/StemPlaylists.swift | 56 +++++++- ios/Stemify/Plex/JellyfinClient.swift | 2 + ios/Stemify/Plex/MediaServer.swift | 2 + ios/Stemify/Plex/PlexClient.swift | 35 +++-- ios/Stemify/Plex/PlexServer.swift | 26 ++++ ios/Stemify/UI/Components/MediaMenu.swift | 9 +- ios/Stemify/UI/Components/Overlays.swift | 20 +++ ios/Stemify/UI/Nav.swift | 2 +- ios/Stemify/UI/RootView.swift | 1 + ios/Stemify/UI/Screens/LibraryScreens.swift | 36 +++-- ios/Stemify/UI/Screens/SettingsScreens.swift | 24 ++++ ios/project.yml | 4 +- 17 files changed, 412 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6cb7d..5be8ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: 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-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. diff --git a/ios/Stemify/Cache/Downloads.swift b/ios/Stemify/Cache/Downloads.swift index 4bca5aa..5e1fe21 100644 --- a/ios/Stemify/Cache/Downloads.swift +++ b/ios/Stemify/Cache/Downloads.swift @@ -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) diff --git a/ios/Stemify/Core/Settings.swift b/ios/Stemify/Core/Settings.swift index fda7996..02a229a 100644 --- a/ios/Stemify/Core/Settings.swift +++ b/ios/Stemify/Core/Settings.swift @@ -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(_ kp: KeyPath, _ 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 } diff --git a/ios/Stemify/Engine/StemEngine.swift b/ios/Stemify/Engine/StemEngine.swift index b738c11..558e005 100644 --- a/ios/Stemify/Engine/StemEngine.swift +++ b/ios/Stemify/Engine/StemEngine.swift @@ -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..= 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?) { 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).., _ n: Int, _ from: Float, _ to: Float) { diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index 8b5a4e9..e292b4f 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -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 diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index 79939d1..bea6c78 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -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:") 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() } diff --git a/ios/Stemify/Plex/JellyfinClient.swift b/ios/Stemify/Plex/JellyfinClient.swift index cea50e2..31507f5 100644 --- a/ios/Stemify/Plex/JellyfinClient.swift +++ b/ios/Stemify/Plex/JellyfinClient.swift @@ -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 } diff --git a/ios/Stemify/Plex/MediaServer.swift b/ios/Stemify/Plex/MediaServer.swift index a7f66cc..c7b697c 100644 --- a/ios/Stemify/Plex/MediaServer.swift +++ b/ios/Stemify/Plex/MediaServer.swift @@ -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] } diff --git a/ios/Stemify/Plex/PlexClient.swift b/ios/Stemify/Plex/PlexClient.swift index fc191c8..fba7aad 100644 --- a/ios/Stemify/Plex/PlexClient.swift +++ b/ios/Stemify/Plex/PlexClient.swift @@ -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? } diff --git a/ios/Stemify/Plex/PlexServer.swift b/ios/Stemify/Plex/PlexServer.swift index 0466fdc..09ad764 100644 --- a/ios/Stemify/Plex/PlexServer.swift +++ b/ios/Stemify/Plex/PlexServer.swift @@ -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 { diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift index eb84af7..98d1ea6 100644 --- a/ios/Stemify/UI/Components/MediaMenu.swift +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -78,7 +78,14 @@ struct MediaMenu { if !pl.items.isEmpty { a.append(SheetAction("Change Cover…", icon: "photo") { var seen = Set() - 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) }) diff --git a/ios/Stemify/UI/Components/Overlays.swift b/ios/Stemify/UI/Components/Overlays.swift index 25fe210..e2d5340 100644 --- a/ios/Stemify/UI/Components/Overlays.swift +++ b/ios/Stemify/UI/Components/Overlays.swift @@ -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) } diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 92ec2d6..d190d80 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -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) diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 4da699e..6f030c4 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -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() diff --git a/ios/Stemify/UI/Screens/LibraryScreens.swift b/ios/Stemify/UI/Screens/LibraryScreens.swift index 668658e..3f03035 100644 --- a/ios/Stemify/UI/Screens/LibraryScreens.swift +++ b/ios/Stemify/UI/Screens/LibraryScreens.swift @@ -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. diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index d98898b..7799824 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -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 { diff --git a/ios/project.yml b/ios/project.yml index ecc51b7..bc9bd6f 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,8 +8,8 @@ settings: SWIFT_VERSION: "5.0" # iOS needs a plain number for the bundle version; the label is what the app and releases show. MARKETING_VERSION: "1.0" - VERSION_LABEL: "1.0 RC 1" - CURRENT_PROJECT_VERSION: "21" + VERSION_LABEL: "1.0 RC 2" + CURRENT_PROJECT_VERSION: "22" targets: Stemify: type: application