iOS 1.0 RC 1: battery and heat work, version label, web player redraw tweaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-06 08:31:00 -08:00
1 parent 0fc35b13e8
commit c0e33b5e3e
18 files changed
+143 -36

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ struct StemifyApp: App {
RootView()
}
.onChange(of: phase) { _, p in
AppActivity.setPhone(p != .background)
guard p == .active else { return }
AppGraph.shared.eq.refreshRoute()
// Keep downloaded playlists in step with the server, like Plexamp.
+10 -3
View File
@@ -34,6 +34,7 @@ final class WidgetBridge: @unchecked Sendable {
private var last: WidgetSnapshot?
private var artKey: String?
private var savedArt: String?
private var pending: DispatchWorkItem?
func start(_ g: AppGraph) {
let pm = g.playback, s = g.settings, plex = g.plex
@@ -46,13 +47,19 @@ final class WidgetBridge: @unchecked Sendable {
}
/// Writes the snapshot and asks WidgetKit to redraw, but only when something the widgets show has changed.
/// Changes settle for a moment first: a fader drag would otherwise relaunch the widget extension every frame.
func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) {
let snap = make(pm, s)
guard force || snap != last else { return }
last = snap
WidgetStore.save(snap)
WidgetCenter.shared.reloadAllTimelines()
if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() }
pending?.cancel()
let push = DispatchWorkItem {
WidgetStore.save(snap)
WidgetCenter.shared.reloadAllTimelines()
if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() }
}
pending = push
if force { push.perform() } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: push) }
}
private func make(_ pm: PlaybackManager, _ s: Settings) -> WidgetSnapshot {
+2
View File
@@ -15,6 +15,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect ic: CPInterfaceController) {
self.ic = ic
AppActivity.setCar(true)
let np = CPNowPlayingTemplate.shared
np.isUpNextButtonEnabled = true
np.isAlbumArtistButtonEnabled = false
@@ -33,6 +34,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow
messageId = nil
CPNowPlayingTemplate.shared.remove(self)
self.ic = nil
AppActivity.setCar(false)
}
// ---- browse ------------------------------------------------------------------------
+38
View File
@@ -0,0 +1,38 @@
import Foundation
/// Whether anyone is looking at the app: the phone UI in the foreground, or CarPlay connected.
/// Work that only feeds a screen (stemd polling, a shallow output buffer for snappy faders)
/// checks this, so a locked phone playing music does as little as it can.
enum AppActivity {
private static let lock = UnfairLock()
nonisolated(unsafe) private static var phone = true
nonisolated(unsafe) private static var car = false
nonisolated(unsafe) private static var waiters: [CheckedContinuation<Void, Never>] = []
static var isActive: Bool { lock.with { phone || car } }
static func setPhone(_ on: Bool) { update { phone = on } }
static func setCar(_ on: Bool) { update { car = on } }
private static func update(_ change: () -> Void) {
let ready = lock.with { () -> [CheckedContinuation<Void, Never>] in
change()
guard phone || car else { return [] }
defer { waiters = [] }
return waiters
}
ready.forEach { $0.resume() }
}
/// Returns once the app is active, at once if it already is.
static func untilActive() async {
await withCheckedContinuation { (c: CheckedContinuation<Void, Never>) in
let now = lock.with { () -> Bool in
if phone || car { return true }
waiters.append(c)
return false
}
if now { c.resume() }
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ final class Log: @unchecked Sendable {
func export() -> URL {
let b = Bundle.main
let d = UIDevice.current
var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") "
var head = "Stemify \((b.object(forInfoDictionaryKey: "StemifyVersionLabel") ?? b.object(forInfoDictionaryKey: "CFBundleShortVersionString")) as? String ?? "?") "
head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
+15 -4
View File
@@ -27,6 +27,10 @@ final class FloatRing: @unchecked Sendable {
private let lock = UnfairLock()
private let notFull = DispatchSemaphore(value: 0)
private var eosFlag = false
/// A writer blocked on a full ring is woken once a quarter of it is free, not after every
/// read, so decoders run in bursts and sleep in between.
private var writerWaiting = false
private var lowWater: Int { cap / 4 }
init(capacityFrames: Int) {
cap = capacityFrames * 2
@@ -42,6 +46,7 @@ final class FloatRing: @unchecked Sendable {
var availableFrames: Int { lock.with { Int((writePos - readPos) / 2) } }
var freeFrames: Int { lock.with { Int((Int64(cap) - (writePos - readPos)) / 2) } }
var capacityFrames: Int { cap / 2 }
/// Blocks while full; returns false if [abort] became true first. [len] counts floats.
func write(_ src: UnsafePointer<Float>, _ len: Int, abort: () -> Bool) -> Bool {
@@ -49,10 +54,12 @@ final class FloatRing: @unchecked Sendable {
while done < len {
lock.lock()
let free = Int(Int64(cap) - (writePos - readPos))
if free <= 0 {
if free < min(len - done, lowWater) {
writerWaiting = true
lock.unlock()
if abort() { return false }
_ = notFull.wait(timeout: .now() + .milliseconds(20))
// seek() and release() signal too, so the timeout is only a backstop.
_ = notFull.wait(timeout: .now() + .milliseconds(250))
continue
}
lock.unlock()
@@ -79,8 +86,10 @@ final class FloatRing: @unchecked Sendable {
dst.update(from: buf + r, count: first)
if n > first { (dst + first).update(from: buf, count: n - first) }
readPos += Int64(n)
let wake = writerWaiting && Int(Int64(cap) - (writePos - readPos)) >= lowWater
if wake { writerWaiting = false }
lock.unlock()
if n > 0 { notFull.signal() }
if wake { notFull.signal() }
return n / 2
}
@@ -88,6 +97,7 @@ final class FloatRing: @unchecked Sendable {
lock.with {
readPos = writePos
eosFlag = false
writerWaiting = false
}
notFull.signal()
}
@@ -210,7 +220,8 @@ final class StemDecoder: @unchecked Sendable {
seek(gen: gen, frame: frame)
let t = Thread { [self] in run() }
t.name = "stem-\(name)"
t.qualityOfService = .userInteractive
// Seconds ahead of the mixer, so efficiency cores are fast enough.
t.qualityOfService = .userInitiated
thread = t
t.start()
}
+13 -4
View File
@@ -201,7 +201,11 @@ final class StemEngine: @unchecked Sendable {
static let ringSeconds = 3
/// Frames each stem must have before (re)starting output.
static let prefill = 8192
/// Mixed audio kept ahead of the speaker. Short while someone's looking, so faders and EQ
/// answer quickly; deep when nobody is, so the engine wakes a few times a second instead of
/// hundreds and the CPU can sleep between bursts.
static let bufferMs = 120
static let backgroundBufferMs = 600
/// Where playback is; position = frame + (now - nanos) while [running].
struct Snapshot {
@@ -446,7 +450,8 @@ final class StemEngine: @unchecked Sendable {
guard let s = session, wantPlay else {
setFlags(false, false)
publish()
idle(100)
// Nothing moves while paused; a command wakes the loop.
idle(2000)
return
}
if let e = s.error() {
@@ -483,9 +488,13 @@ final class StemEngine: @unchecked Sendable {
setFlags(true, false)
guard let tap else { return }
if tap.ring.freeFrames < Self.chunk {
let target = min(trackRate * (AppActivity.isActive ? Self.bufferMs : Self.backgroundBufferMs) / 1000, tap.ring.capacityFrames)
let buffered = tap.ring.availableFrames
if buffered + Self.chunk > target {
publish()
idle(5)
// Sleep until the buffer is half drained, then top it up in one burst.
let ms = Double(buffered - target / 2) * 1000 / Double(max(1, trackRate)) / speed
idle(max(5, Int(ms)))
return
}
let n = mixChunk(s)
@@ -620,7 +629,7 @@ final class StemEngine: @unchecked Sendable {
#if os(iOS)
try? AVAudioSession.sharedInstance().setPreferredSampleRate(Double(rate))
#endif
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.bufferMs / 1000))
let t = OutputTap(rate: rate, frames: max(4096, rate * Self.backgroundBufferMs / 1000))
t.setLatency(Self.outputLatency())
t.speed = speed
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
+1 -1
View File
@@ -168,7 +168,7 @@ final class JellyfinClient: MediaSource, @unchecked Sendable {
r.httpMethod = method
r.setValue("application/json", forHTTPHeaderField: "Accept")
let t = token ?? server.token
var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.1\""
var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.0\""
if !t.isEmpty { auth += ", Token=\"\(t)\"" }
r.setValue(auth, forHTTPHeaderField: "Authorization")
return r
+4 -1
View File
@@ -70,9 +70,11 @@ final class StemServer: @unchecked Sendable {
index = StemIndex(parsed.tracks, roots: roots())
}
// stemd on the server separates new music around the clock; look for its new stems
// every minute. Unchanged indexes cost one 304.
// every minute. Unchanged indexes cost one 304. Neither loop runs while nobody's looking:
// a radio wake every few seconds is most of a locked phone's battery drain.
Task.detached { [weak self] in
while let self {
await AppActivity.untilActive()
await self.refresh()
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
}
@@ -80,6 +82,7 @@ final class StemServer: @unchecked Sendable {
// Watch stemd's queue: every few seconds while it has work, slowly when idle.
Task.detached { [weak self] in
while let self {
await AppActivity.untilActive()
await self.pollQueue()
try? await Task.sleep(nanoseconds: (self.busy ? 4 : 30) * 1_000_000_000)
}
+2 -1
View File
@@ -229,7 +229,8 @@ struct PlayingBars: View {
var color: Color?
@Environment(\.palette) private var p
var body: some View {
TimelineView(.animation(paused: !playing)) { tl in
// 30 fps is plenty for a 16 pt glyph; ProMotion would otherwise redraw it 120 times a second.
TimelineView(.animation(minimumInterval: 1 / 30, paused: !playing)) { tl in
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
Canvas { ctx, size in
let w = size.width / 4.5
@@ -521,7 +521,9 @@ struct ProgressVolumeBar: View {
let dur = pm.durationMs
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
let adjusting = axis == 2
TimelineView(.animation(paused: !pm.playWhenReady || scrub != nil)) { _ in
// Bars are discrete and the clock shows seconds, so a few redraws a second look the same
// as 120. A drag redraws through [scrub] and the volume through [master].
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady || scrub != nil)) { _ in
let pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
VStack(spacing: 12) {
@@ -711,7 +713,8 @@ private struct MixerPanel: View {
.padding(.vertical, 8)
}
}
TimelineView(.animation(paused: !stems || !pm.playWhenReady)) { _ in
// The meters' fall is timed, so 30 fps looks the same and costs a quarter of 120.
TimelineView(.animation(minimumInterval: 1 / 30, paused: !stems || !pm.playWhenReady)) { _ in
let levels = stems ? meters.step(pm.engine, names.count) : []
VStack(spacing: 0) {
ForEach(Array(names.enumerated()), id: \.element) { i, name in
@@ -1010,7 +1013,7 @@ struct MiniPlayer: View {
}
.overlay(alignment: .bottom) {
// Thin progress line along the bottom of the card.
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
TimelineView(.animation(minimumInterval: 0.5, paused: !pm.playWhenReady)) { _ in
let d = pm.durationMs
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
+2 -1
View File
@@ -169,7 +169,8 @@ private struct PathStop: View {
private let appVersion: String = {
let info = Bundle.main.infoDictionary
return "\(info?["CFBundleShortVersionString"] as? String ?? "1.1") (\(info?["CFBundleVersion"] as? String ?? "1"))"
let label = info?["StemifyVersionLabel"] as? String ?? info?["CFBundleShortVersionString"] as? String ?? "1.0"
return "\(label) (\(info?["CFBundleVersion"] as? String ?? "1"))"
}()
// ---- settings -------------------------------------------------------------------------------