Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -2,28 +2,74 @@ import Foundation
|
||||
|
||||
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
|
||||
struct MixPreset: Identifiable, Equatable {
|
||||
let label: String
|
||||
/// The stored name, which ids are built from. Show [label] instead.
|
||||
let name: String
|
||||
/// One the user saved, as opposed to a built-in.
|
||||
var custom = false
|
||||
let build: ([String]) -> [String: StemSetting]?
|
||||
var id: String { label }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
|
||||
var id: String { (custom ? "c:" : "b:") + name }
|
||||
/// The name as shown, with A Cappella spelled the way Appearance says.
|
||||
var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id }
|
||||
}
|
||||
|
||||
enum MixPresets {
|
||||
static let full = MixPreset(label: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
static let full = MixPreset(name: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(name: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
|
||||
static let all: [MixPreset] = [
|
||||
full,
|
||||
karaoke,
|
||||
MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "Rhythm") { n in
|
||||
MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "Rhythm") { n in
|
||||
n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil
|
||||
},
|
||||
MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
]
|
||||
|
||||
static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } }
|
||||
|
||||
/// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end).
|
||||
static func ordered(_ s: Settings) -> [MixPreset] {
|
||||
let pool = CustomPreset.load(s).map(custom) + all
|
||||
let order = s.presetOrder.split(separator: "\n").map(String.init)
|
||||
let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
return pool.enumerated().sorted { a, b in
|
||||
(rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset)
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
/// Set by Appearance. Read straight from UserDefaults so labels work off the main thread.
|
||||
static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") }
|
||||
|
||||
/// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count.
|
||||
static func hidden(_ s: Settings) -> Set<String> {
|
||||
Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") })
|
||||
}
|
||||
|
||||
/// The chips in the mixer bar, and the rotation for every Mix button outside the app.
|
||||
static func bar(_ s: Settings) -> [MixPreset] {
|
||||
let h = hidden(s)
|
||||
return ordered(s).filter { !h.contains($0.id) }
|
||||
}
|
||||
|
||||
static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" }
|
||||
|
||||
static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") }
|
||||
|
||||
static func setHidden(_ s: Settings, _ id: String, _ on: Bool) {
|
||||
var h = hidden(s)
|
||||
if on { h.insert(id) } else { h.remove(id) }
|
||||
s.hiddenPresets = h.sorted().joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked.
|
||||
static func locked(_ s: Settings) -> MixPreset? {
|
||||
s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset }
|
||||
}
|
||||
|
||||
/// Drops stems left at their defaults, so mixes compare by what they actually change.
|
||||
static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } }
|
||||
|
||||
@@ -33,10 +79,19 @@ enum MixPresets {
|
||||
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
|
||||
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = all.filter { $0.build(names) != nil }
|
||||
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
|
||||
/// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows.
|
||||
static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
|
||||
let m = normalized(mix)
|
||||
return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do.
|
||||
/// A hand-made mix, or one deleted from the editor, goes to the start of the list.
|
||||
static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = bar(s).filter { $0.build(names) != nil }
|
||||
guard !usable.isEmpty else { return full }
|
||||
let m = normalized(mix)
|
||||
guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] }
|
||||
return usable[(i + 1) % usable.count]
|
||||
}
|
||||
|
||||
@@ -48,3 +103,41 @@ enum MixPresets {
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets].
|
||||
struct CustomPreset: Codable, Identifiable, Equatable {
|
||||
var name: String
|
||||
var mix: [String: StemSetting]
|
||||
var id: String { name }
|
||||
|
||||
@MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) }
|
||||
|
||||
static func load(_ s: Settings) -> [CustomPreset] {
|
||||
guard let d = s.presets.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? []
|
||||
}
|
||||
|
||||
@MainActor static func save(_ list: [CustomPreset]) {
|
||||
guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return }
|
||||
AppGraph.shared.settings.presets = s
|
||||
}
|
||||
|
||||
/// Adds or replaces the preset with this name.
|
||||
@MainActor static func add(_ name: String, _ mix: [String: StemSetting]) {
|
||||
var l = load().filter { $0.name != name }
|
||||
l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix)))
|
||||
save(l)
|
||||
}
|
||||
|
||||
@MainActor static func remove(_ name: String) {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset == "c:" + name { s.lockedPreset = "" }
|
||||
save(load().filter { $0.name != name })
|
||||
}
|
||||
|
||||
@MainActor static func removeAll() {
|
||||
let s = AppGraph.shared.settings
|
||||
if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" }
|
||||
save([])
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import UIKit
|
||||
final class NowPlaying: @unchecked Sendable {
|
||||
private let pm: PlaybackManager
|
||||
private let plex: PlexClient
|
||||
private let radio: WebRadio
|
||||
private let settings: Settings
|
||||
private let center = MPNowPlayingInfoCenter.default()
|
||||
private var artKey: String?
|
||||
private var artwork: MPMediaItemArtwork?
|
||||
|
||||
init(pm: PlaybackManager, plex: PlexClient) {
|
||||
init(pm: PlaybackManager, plex: PlexClient, radio: WebRadio, settings: Settings) {
|
||||
self.pm = pm
|
||||
self.plex = plex
|
||||
self.radio = radio
|
||||
self.settings = settings
|
||||
pm.addListener { [weak self] in self?.publish() }
|
||||
wireCommands()
|
||||
publish()
|
||||
@@ -25,13 +29,14 @@ final class NowPlaying: @unchecked Sendable {
|
||||
/// The mix shown next to the artist: nil unless stems are playing or the mix is Full.
|
||||
var mixLabel: String? {
|
||||
guard pm.source == .Stems else { return nil }
|
||||
guard let p = MixPresets.current(pm.stemNames, pm.mix) else { return "Custom mix" }
|
||||
guard let p = MixPresets.current(settings, pm.stemNames, pm.mix) else { return "Custom mix" }
|
||||
return p == MixPresets.full ? nil : p.label
|
||||
}
|
||||
|
||||
var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") }
|
||||
var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) }
|
||||
var nextMix: MixPreset { MixPresets.next(pm.stemNames, pm.mix) }
|
||||
/// Follows the Stem Editor: deleted mixes are skipped and the user's own are in the rotation.
|
||||
var nextMix: MixPreset { MixPresets.next(settings, pm.stemNames, pm.mix) }
|
||||
|
||||
/// One tap: hear the vocals again, or take them out.
|
||||
func toggleVocals() {
|
||||
@@ -50,7 +55,11 @@ final class NowPlaying: @unchecked Sendable {
|
||||
|
||||
// ---- info --------------------------------------------------------------------------
|
||||
|
||||
/// Puts the queue back on the lock screen (after the web radio stops).
|
||||
func refresh() { publish() }
|
||||
|
||||
private func publish() {
|
||||
if radio.active { return }
|
||||
guard let t = pm.current?.track else {
|
||||
center.nowPlayingInfo = nil
|
||||
center.playbackState = .stopped
|
||||
@@ -92,16 +101,21 @@ final class NowPlaying: @unchecked Sendable {
|
||||
|
||||
private func wireCommands() {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
func on(_ cmd: MPRemoteCommand, _ f: @escaping @MainActor (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
|
||||
cmd.isEnabled = true
|
||||
cmd.addTarget { e in MainActor.assumeIsolated { f(e) } }
|
||||
}
|
||||
on(c.playCommand) { [pm] _ in pm.play(); return .success }
|
||||
on(c.pauseCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.stopCommand) { [pm] _ in pm.pause(); return .success }
|
||||
on(c.togglePlayPauseCommand) { [pm] _ in pm.togglePlay(); return .success }
|
||||
on(c.nextTrackCommand) { [pm] _ in pm.next(); return .success }
|
||||
on(c.previousTrackCommand) { [pm] _ in pm.previous(); return .success }
|
||||
// While a web radio station is loaded, play/pause drive it and skipping does nothing.
|
||||
on(c.playCommand) { [pm, radio] _ in if radio.active { radio.resume() } else { pm.play() }; return .success }
|
||||
on(c.pauseCommand) { [pm, radio] _ in if radio.active { radio.pause() } else { pm.pause() }; return .success }
|
||||
on(c.stopCommand) { [pm, radio] _ in if radio.active { radio.stop() } else { pm.pause() }; return .success }
|
||||
on(c.togglePlayPauseCommand) { [pm, radio] _ in if radio.active { radio.toggle() } else { pm.togglePlay() }; return .success }
|
||||
on(c.nextTrackCommand) { [pm, radio] _ in if !radio.active { pm.next() }; return .success }
|
||||
on(c.previousTrackCommand) { [weak self, pm, radio] _ in
|
||||
guard !radio.active else { return .success }
|
||||
if self?.settings.lockScreenMix == true && pm.source == .Stems { self?.cycleMix() } else { pm.previous() }
|
||||
return .success
|
||||
}
|
||||
on(c.changePlaybackPositionCommand) { [pm] e in
|
||||
guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
pm.seekTo(Int64(e.positionTime * 1000))
|
||||
|
||||
@@ -100,7 +100,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
"stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)"
|
||||
}
|
||||
static func plexKey(_ track: Track) -> String {
|
||||
"plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
"plex|\(track.ratingKey.firstIndex(of: "~").map { String(track.ratingKey[...$0]) } ?? "")\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
}
|
||||
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@@ -131,6 +131,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
private(set) var songMixes: [String: [String: StemSetting]] = [:]
|
||||
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
|
||||
private(set) var karaoke = false
|
||||
/// The last mix set by hand; the next song starts from it (never a song's saved mix).
|
||||
@ObservationIgnored private var carryMix: [String: StemSetting] = [:]
|
||||
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||
|
||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||
@@ -142,9 +144,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var nextTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var currentToken: Int64 = -1
|
||||
@ObservationIgnored private var nextToken: Int64 = -1
|
||||
@ObservationIgnored private var nextIndex = -1
|
||||
@ObservationIgnored private var nextEntry: StemEntry?
|
||||
/// Sessions handed to the engine for a gapless switch, by token: which queue item and entry they are.
|
||||
/// Kept until a switch or load, since the engine may already have switched when the queue changes.
|
||||
@ObservationIgnored private var queued: [Int64: (uid: Int64, entry: StemEntry?)] = [:]
|
||||
/// The queue ran out and playback stopped at its last song.
|
||||
@ObservationIgnored private var finished = false
|
||||
@ObservationIgnored private var currentEntry: StemEntry?
|
||||
@ObservationIgnored private var pendingPositionMs: Int64 = 0
|
||||
@ObservationIgnored private var failures = 0
|
||||
@@ -218,13 +222,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
|
||||
/// Shuffles whole albums, keeping each album's tracks in order (Plexamp-style), when the setting is on.
|
||||
private func shuffled(_ items: [QueueItem]) -> [QueueItem] {
|
||||
items.shuffled()
|
||||
}
|
||||
|
||||
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks, mixes)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
|
||||
queue = [first] + shuffled(items.filter { $0.uid != first.uid })
|
||||
shuffle = true
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
@@ -245,8 +254,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
unshuffled = unshuffled.map { $0 + items }
|
||||
if index < 0 {
|
||||
load(0, 0, true)
|
||||
if index < 0 || finished {
|
||||
load(finished ? index + 1 : 0, 0, true)
|
||||
} else {
|
||||
requeueNext()
|
||||
changed()
|
||||
@@ -303,7 +312,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
let cur = q.indices.contains(idx) ? q[idx] : nil
|
||||
if !shuffle {
|
||||
unshuffled = q
|
||||
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
|
||||
queue = Array(q.prefix(idx + 1)) + shuffled(Array(q.dropFirst(idx + 1)))
|
||||
shuffle = true
|
||||
} else {
|
||||
let orig = unshuffled ?? q
|
||||
@@ -376,6 +385,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
/// Skips past the rest of the current album (holding Next).
|
||||
func nextAlbum() {
|
||||
guard let album = current?.track.albumKey, index >= 0 else { return next() }
|
||||
if let i = queue.indices.dropFirst(index + 1).first(where: { queue[$0].track.albumKey != album }) { load(i, 0, playWhenReady) }
|
||||
else if repeatMode != .Off { load(0, 0, playWhenReady) }
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
func previous() {
|
||||
if positionMs() > 3000 || index <= 0 {
|
||||
seekTo(0)
|
||||
@@ -401,7 +418,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
engine.stop()
|
||||
reportTimeline("stopped")
|
||||
currentToken = -1
|
||||
nextToken = -1
|
||||
queued = [:]
|
||||
queue = []
|
||||
index = -1
|
||||
current = nil
|
||||
@@ -428,14 +445,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
/// Karaoke and friends: set every stem at once.
|
||||
func applyMix(_ m: [String: StemSetting]) {
|
||||
mix = m
|
||||
carryMix = m
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
|
||||
/// else cleared (or kept, per settings).
|
||||
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
|
||||
if karaoke && !settings.keepStemMix { karaoke = false; preKaraokeMix = nil }
|
||||
if karaoke { return karaokeMix }
|
||||
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
if let l = MixPresets.locked(settings), let m = l.build(StemPlaylists.allStems) { return m }
|
||||
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? carryMix : [:])
|
||||
}
|
||||
|
||||
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||
@@ -491,6 +511,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
var s = mix[name] ?? StemSetting()
|
||||
f(&s)
|
||||
mix[name] = s
|
||||
carryMix = mix
|
||||
pushMix()
|
||||
}
|
||||
|
||||
@@ -533,7 +554,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
let item = queue[i]
|
||||
loadTask?.cancel()
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
queued = [:]
|
||||
finished = false
|
||||
engine.queueNext(nil)
|
||||
index = i
|
||||
current = item
|
||||
@@ -649,7 +671,6 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
/// Builds the next track's session now so the switch can be gapless.
|
||||
private func prepareNext() {
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
guard let i = upcomingIndex() else { return }
|
||||
let item = queue[i]
|
||||
@@ -666,10 +687,27 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.nextToken = s.token
|
||||
self.nextIndex = i
|
||||
self.nextEntry = entry
|
||||
self.queued[s.token] = (item.uid, entry)
|
||||
self.engine.queueNext(s)
|
||||
await self.precacheAhead(from: i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Warms the cache for the songs after the gapless one, up to the Cache Ahead setting.
|
||||
private func precacheAhead(from i: Int) async {
|
||||
let n = settings.precacheCount - 1
|
||||
guard n > 0 else { return }
|
||||
for k in 1...n {
|
||||
let j = i + k
|
||||
guard !Task.isCancelled, j < queue.count else { return }
|
||||
let t = queue[j].track
|
||||
if playableOffline(t) { continue }
|
||||
guard let (s, _) = try? await openDetached(t, 0) else { continue }
|
||||
// Keep the session open until its prefetch has filled the cache.
|
||||
for _ in 0..<300 where !Task.isCancelled && !playableOffline(t) {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
s.release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,13 +747,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func onTransition(_ token: Int64) {
|
||||
if token == currentToken || token != nextToken { return }
|
||||
guard token != currentToken, let q = queued[token] else { return }
|
||||
queued = [:]
|
||||
scrobble()
|
||||
currentToken = token
|
||||
nextToken = -1
|
||||
index = nextIndex
|
||||
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
|
||||
currentEntry = nextEntry
|
||||
let i = queue.firstIndex { $0.uid == q.uid } ?? min(index + 1, queue.count - 1)
|
||||
index = i
|
||||
current = queue.indices.contains(i) ? queue[i] : nil
|
||||
currentEntry = q.entry
|
||||
pendingPositionMs = 0
|
||||
scrobbled = -1
|
||||
guard let item = current else { return }
|
||||
@@ -743,6 +782,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
}
|
||||
playWhenReady = false
|
||||
pendingPositionMs = 0
|
||||
finished = true
|
||||
reportTimeline("stopped")
|
||||
changed()
|
||||
save()
|
||||
|
||||
@@ -30,7 +30,7 @@ final class StemPlaylists {
|
||||
@ObservationIgnored private let file: URL
|
||||
|
||||
/// Every stem a song can have, for naming saved mixes without loading the song.
|
||||
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||
nonisolated static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||
|
||||
init(dir: URL) {
|
||||
file = dir.appendingPathComponent("stem-playlists.json")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
/// An internet radio stream the user saved in Browse > Web Radio.
|
||||
struct WebStation: Codable, Hashable, Identifiable {
|
||||
var id = UUID().uuidString
|
||||
var name: String
|
||||
var url: String
|
||||
}
|
||||
|
||||
/// Plays a web radio stream through AVPlayer, outside the stem engine. Starting a station pauses
|
||||
/// the queue, and playing the queue again stops the station. Main-thread only, like PlaybackManager.
|
||||
@Observable
|
||||
final class WebRadio: @unchecked Sendable {
|
||||
private let pm: PlaybackManager
|
||||
@ObservationIgnored private let player = AVPlayer()
|
||||
@ObservationIgnored weak var nowPlaying: NowPlaying?
|
||||
|
||||
private(set) var station: WebStation?
|
||||
private(set) var playing = false
|
||||
/// A station is loaded (playing or paused); the lock screen belongs to the radio.
|
||||
var active: Bool { station != nil }
|
||||
|
||||
init(pm: PlaybackManager) {
|
||||
self.pm = pm
|
||||
player.automaticallyWaitsToMinimizeStalling = true
|
||||
pm.addListener { [weak self] in
|
||||
guard let self, self.active, pm.playWhenReady else { return }
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func play(_ s: WebStation) {
|
||||
guard let url = URL(string: s.url) else { return }
|
||||
station = s
|
||||
if pm.playWhenReady { pm.pause() }
|
||||
let a = AVAudioSession.sharedInstance()
|
||||
try? a.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
try? a.setActive(true)
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||
player.play()
|
||||
playing = true
|
||||
publish()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard active else { return }
|
||||
// Live streams go stale while paused; rejoin at the live edge.
|
||||
if let s = station { play(s) }
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
playing = false
|
||||
publish()
|
||||
}
|
||||
|
||||
func toggle() { if playing { pause() } else { resume() } }
|
||||
|
||||
func stop() {
|
||||
player.pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
station = nil
|
||||
playing = false
|
||||
nowPlaying?.refresh()
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard let s = station else { return }
|
||||
let c = MPNowPlayingInfoCenter.default()
|
||||
c.nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: s.name,
|
||||
MPMediaItemPropertyArtist: "Web Radio",
|
||||
MPNowPlayingInfoPropertyIsLiveStream: true,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: playing ? 1.0 : 0.0,
|
||||
]
|
||||
c.playbackState = playing ? .playing : .paused
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user