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:
1 parent
0fc35b13e8
commit
c0e33b5e3e
18 files changed
+143
-36
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ------------------------------------------------------------------------
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -------------------------------------------------------------------------------
|
||||
|
||||
+5
-2
@@ -6,8 +6,10 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.1"
|
||||
CURRENT_PROJECT_VERSION: "20"
|
||||
# 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"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
@@ -33,6 +35,7 @@ targets:
|
||||
CFBundleDisplayName: Stemify
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
StemifyVersionLabel: $(VERSION_LABEL)
|
||||
UILaunchScreen: {}
|
||||
UIBackgroundModes: [audio, fetch]
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
|
||||
@@ -9,7 +9,8 @@ cd "$(dirname "$0")/.."
|
||||
export PATH=/opt/homebrew/bin:$PATH
|
||||
B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex
|
||||
rm -rf $OUT && mkdir -p $OUT/Payload $B/public $B/prov
|
||||
VERSION=$(sed -nE 's/^ *MARKETING_VERSION: "(.*)"/\1/p' project.yml)
|
||||
# "1.0 RC 1" -> 1.0-rc1, used in file names and release tags.
|
||||
VERSION=$(sed -nE 's/^ *VERSION_LABEL: "(.*)"/\1/p' project.yml | tr 'A-Z' 'a-z' | sed -E 's/ rc /-rc/; s/ /-/g')
|
||||
|
||||
# xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata.
|
||||
xcodegen generate -q
|
||||
|
||||
@@ -23,11 +23,14 @@ yml = open(os.path.join(HERE, "..", "project.yml")).read()
|
||||
builds = {}
|
||||
try:
|
||||
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
|
||||
builds[v["version"]] = v["buildVersion"]
|
||||
builds[v["downloadURL"].rsplit("/stemify-", 1)[-1].removesuffix(".ipa")] = v["buildVersion"]
|
||||
except (OSError, ValueError, KeyError, IndexError):
|
||||
pass
|
||||
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
|
||||
builds[m[0][1]] = m[1][1]
|
||||
# Tags and IPAs use the label as a slug ("1.0 RC 1" -> 1.0-rc1); AltStore gets the plain bundle
|
||||
# version, so it matches what's installed, and the label to show.
|
||||
label = re.search(r'VERSION_LABEL: "(.*)"', yml)[1]
|
||||
slug = label.lower().replace(" rc ", "-rc").replace(" ", "-")
|
||||
builds[slug] = re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)[1]
|
||||
|
||||
versions = []
|
||||
for rel in get(API + "/releases?limit=50"):
|
||||
@@ -36,7 +39,8 @@ for rel in get(API + "/releases?limit=50"):
|
||||
if not rel["tag_name"].startswith("ios-v") or not ipa:
|
||||
continue
|
||||
versions.append({
|
||||
"version": v,
|
||||
"version": v.split("-")[0],
|
||||
"marketingVersion": v.replace("-rc", " RC ").replace("-", " ") if "-" in v else v,
|
||||
"buildVersion": builds.get(v, v),
|
||||
"date": rel["published_at"],
|
||||
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
|
||||
|
||||
Reference in new issue
Block a user