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
+138
-31
No files matched your search
@@ -17,3 +17,6 @@
|
|||||||
2026-10-28 iOS: CarPlay Downloads Playlists, Albums and Artists load from local downloads, work offline
|
2026-10-28 iOS: CarPlay Downloads Playlists, Albums and Artists load from local downloads, work offline
|
||||||
2026-10-28 iOS: Stem playlist metadata refresh matches by key, file, then title/artist with a search fallback, and also runs when the app returns to the foreground
|
2026-10-28 iOS: Stem playlist metadata refresh matches by key, file, then title/artist with a search fallback, and also runs when the app returns to the foreground
|
||||||
2026-10-29 server: stemd prunes stems whose source song is gone (orphans). PRUNE, PRUNE_CHANGED, PRUNE_MAX env. server/prune.py is a dry-run CLI.
|
2026-10-29 server: stemd prunes stems whose source song is gone (orphans). PRUNE, PRUNE_CHANGED, PRUNE_MAX env. server/prune.py is a dry-run CLI.
|
||||||
|
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.
|
||||||
@@ -36,6 +36,7 @@ struct StemifyApp: App {
|
|||||||
RootView()
|
RootView()
|
||||||
}
|
}
|
||||||
.onChange(of: phase) { _, p in
|
.onChange(of: phase) { _, p in
|
||||||
|
AppActivity.setPhone(p != .background)
|
||||||
guard p == .active else { return }
|
guard p == .active else { return }
|
||||||
AppGraph.shared.eq.refreshRoute()
|
AppGraph.shared.eq.refreshRoute()
|
||||||
// Keep downloaded playlists in step with the server, like Plexamp.
|
// 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 last: WidgetSnapshot?
|
||||||
private var artKey: String?
|
private var artKey: String?
|
||||||
private var savedArt: String?
|
private var savedArt: String?
|
||||||
|
private var pending: DispatchWorkItem?
|
||||||
|
|
||||||
func start(_ g: AppGraph) {
|
func start(_ g: AppGraph) {
|
||||||
let pm = g.playback, s = g.settings, plex = g.plex
|
let pm = g.playback, s = g.settings, plex = g.plex
|
||||||
@@ -46,14 +47,20 @@ final class WidgetBridge: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the snapshot and asks WidgetKit to redraw, but only when something the widgets show has changed.
|
/// 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) {
|
func publish(_ pm: PlaybackManager, _ s: Settings, force: Bool = false) {
|
||||||
let snap = make(pm, s)
|
let snap = make(pm, s)
|
||||||
guard force || snap != last else { return }
|
guard force || snap != last else { return }
|
||||||
last = snap
|
last = snap
|
||||||
|
pending?.cancel()
|
||||||
|
let push = DispatchWorkItem {
|
||||||
WidgetStore.save(snap)
|
WidgetStore.save(snap)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
if #available(iOS 18.0, *) { ControlCenter.shared.reloadAllControls() }
|
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 {
|
private func make(_ pm: PlaybackManager, _ s: Settings) -> WidgetSnapshot {
|
||||||
guard let t = pm.current?.track else { return WidgetSnapshot() }
|
guard let t = pm.current?.track else { return WidgetSnapshot() }
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow
|
|||||||
|
|
||||||
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect ic: CPInterfaceController) {
|
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect ic: CPInterfaceController) {
|
||||||
self.ic = ic
|
self.ic = ic
|
||||||
|
AppActivity.setCar(true)
|
||||||
let np = CPNowPlayingTemplate.shared
|
let np = CPNowPlayingTemplate.shared
|
||||||
np.isUpNextButtonEnabled = true
|
np.isUpNextButtonEnabled = true
|
||||||
np.isAlbumArtistButtonEnabled = false
|
np.isAlbumArtistButtonEnabled = false
|
||||||
@@ -33,6 +34,7 @@ final class CarPlayScene: UIResponder, CPTemplateApplicationSceneDelegate, CPNow
|
|||||||
messageId = nil
|
messageId = nil
|
||||||
CPNowPlayingTemplate.shared.remove(self)
|
CPNowPlayingTemplate.shared.remove(self)
|
||||||
self.ic = nil
|
self.ic = nil
|
||||||
|
AppActivity.setCar(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- browse ------------------------------------------------------------------------
|
// ---- 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 {
|
func export() -> URL {
|
||||||
let b = Bundle.main
|
let b = Bundle.main
|
||||||
let d = UIDevice.current
|
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 += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
|
||||||
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
|
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
|
||||||
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
|
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ final class FloatRing: @unchecked Sendable {
|
|||||||
private let lock = UnfairLock()
|
private let lock = UnfairLock()
|
||||||
private let notFull = DispatchSemaphore(value: 0)
|
private let notFull = DispatchSemaphore(value: 0)
|
||||||
private var eosFlag = false
|
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) {
|
init(capacityFrames: Int) {
|
||||||
cap = capacityFrames * 2
|
cap = capacityFrames * 2
|
||||||
@@ -42,6 +46,7 @@ final class FloatRing: @unchecked Sendable {
|
|||||||
|
|
||||||
var availableFrames: Int { lock.with { Int((writePos - readPos) / 2) } }
|
var availableFrames: Int { lock.with { Int((writePos - readPos) / 2) } }
|
||||||
var freeFrames: Int { lock.with { Int((Int64(cap) - (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.
|
/// Blocks while full; returns false if [abort] became true first. [len] counts floats.
|
||||||
func write(_ src: UnsafePointer<Float>, _ len: Int, abort: () -> Bool) -> Bool {
|
func write(_ src: UnsafePointer<Float>, _ len: Int, abort: () -> Bool) -> Bool {
|
||||||
@@ -49,10 +54,12 @@ final class FloatRing: @unchecked Sendable {
|
|||||||
while done < len {
|
while done < len {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
let free = Int(Int64(cap) - (writePos - readPos))
|
let free = Int(Int64(cap) - (writePos - readPos))
|
||||||
if free <= 0 {
|
if free < min(len - done, lowWater) {
|
||||||
|
writerWaiting = true
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
if abort() { return false }
|
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
|
continue
|
||||||
}
|
}
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -79,8 +86,10 @@ final class FloatRing: @unchecked Sendable {
|
|||||||
dst.update(from: buf + r, count: first)
|
dst.update(from: buf + r, count: first)
|
||||||
if n > first { (dst + first).update(from: buf, count: n - first) }
|
if n > first { (dst + first).update(from: buf, count: n - first) }
|
||||||
readPos += Int64(n)
|
readPos += Int64(n)
|
||||||
|
let wake = writerWaiting && Int(Int64(cap) - (writePos - readPos)) >= lowWater
|
||||||
|
if wake { writerWaiting = false }
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
if n > 0 { notFull.signal() }
|
if wake { notFull.signal() }
|
||||||
return n / 2
|
return n / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ final class FloatRing: @unchecked Sendable {
|
|||||||
lock.with {
|
lock.with {
|
||||||
readPos = writePos
|
readPos = writePos
|
||||||
eosFlag = false
|
eosFlag = false
|
||||||
|
writerWaiting = false
|
||||||
}
|
}
|
||||||
notFull.signal()
|
notFull.signal()
|
||||||
}
|
}
|
||||||
@@ -210,7 +220,8 @@ final class StemDecoder: @unchecked Sendable {
|
|||||||
seek(gen: gen, frame: frame)
|
seek(gen: gen, frame: frame)
|
||||||
let t = Thread { [self] in run() }
|
let t = Thread { [self] in run() }
|
||||||
t.name = "stem-\(name)"
|
t.name = "stem-\(name)"
|
||||||
t.qualityOfService = .userInteractive
|
// Seconds ahead of the mixer, so efficiency cores are fast enough.
|
||||||
|
t.qualityOfService = .userInitiated
|
||||||
thread = t
|
thread = t
|
||||||
t.start()
|
t.start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,11 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
static let ringSeconds = 3
|
static let ringSeconds = 3
|
||||||
/// Frames each stem must have before (re)starting output.
|
/// Frames each stem must have before (re)starting output.
|
||||||
static let prefill = 8192
|
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 bufferMs = 120
|
||||||
|
static let backgroundBufferMs = 600
|
||||||
|
|
||||||
/// Where playback is; position = frame + (now - nanos) while [running].
|
/// Where playback is; position = frame + (now - nanos) while [running].
|
||||||
struct Snapshot {
|
struct Snapshot {
|
||||||
@@ -446,7 +450,8 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
guard let s = session, wantPlay else {
|
guard let s = session, wantPlay else {
|
||||||
setFlags(false, false)
|
setFlags(false, false)
|
||||||
publish()
|
publish()
|
||||||
idle(100)
|
// Nothing moves while paused; a command wakes the loop.
|
||||||
|
idle(2000)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let e = s.error() {
|
if let e = s.error() {
|
||||||
@@ -483,9 +488,13 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
setFlags(true, false)
|
setFlags(true, false)
|
||||||
|
|
||||||
guard let tap else { return }
|
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()
|
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
|
return
|
||||||
}
|
}
|
||||||
let n = mixChunk(s)
|
let n = mixChunk(s)
|
||||||
@@ -620,7 +629,7 @@ final class StemEngine: @unchecked Sendable {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
try? AVAudioSession.sharedInstance().setPreferredSampleRate(Double(rate))
|
try? AVAudioSession.sharedInstance().setPreferredSampleRate(Double(rate))
|
||||||
#endif
|
#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.setLatency(Self.outputLatency())
|
||||||
t.speed = speed
|
t.speed = speed
|
||||||
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
guard let fmt = AVAudioFormat(standardFormatWithSampleRate: Double(rate), channels: 2) else {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ final class JellyfinClient: MediaSource, @unchecked Sendable {
|
|||||||
r.httpMethod = method
|
r.httpMethod = method
|
||||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
let t = token ?? server.token
|
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)\"" }
|
if !t.isEmpty { auth += ", Token=\"\(t)\"" }
|
||||||
r.setValue(auth, forHTTPHeaderField: "Authorization")
|
r.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ final class StemServer: @unchecked Sendable {
|
|||||||
index = StemIndex(parsed.tracks, roots: roots())
|
index = StemIndex(parsed.tracks, roots: roots())
|
||||||
}
|
}
|
||||||
// stemd on the server separates new music around the clock; look for its new stems
|
// 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
|
Task.detached { [weak self] in
|
||||||
while let self {
|
while let self {
|
||||||
|
await AppActivity.untilActive()
|
||||||
await self.refresh()
|
await self.refresh()
|
||||||
try? await Task.sleep(nanoseconds: Self.checkSeconds * 1_000_000_000)
|
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.
|
// Watch stemd's queue: every few seconds while it has work, slowly when idle.
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
while let self {
|
while let self {
|
||||||
|
await AppActivity.untilActive()
|
||||||
await self.pollQueue()
|
await self.pollQueue()
|
||||||
try? await Task.sleep(nanoseconds: (self.busy ? 4 : 30) * 1_000_000_000)
|
try? await Task.sleep(nanoseconds: (self.busy ? 4 : 30) * 1_000_000_000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ struct PlayingBars: View {
|
|||||||
var color: Color?
|
var color: Color?
|
||||||
@Environment(\.palette) private var p
|
@Environment(\.palette) private var p
|
||||||
var body: some View {
|
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)
|
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
|
||||||
Canvas { ctx, size in
|
Canvas { ctx, size in
|
||||||
let w = size.width / 4.5
|
let w = size.width / 4.5
|
||||||
|
|||||||
@@ -521,7 +521,9 @@ struct ProgressVolumeBar: View {
|
|||||||
let dur = pm.durationMs
|
let dur = pm.durationMs
|
||||||
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
|
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
|
||||||
let adjusting = axis == 2
|
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 pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
|
||||||
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
|
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -711,7 +713,8 @@ private struct MixerPanel: View {
|
|||||||
.padding(.vertical, 8)
|
.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) : []
|
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||||
@@ -1010,7 +1013,7 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
// Thin progress line along the bottom of the card.
|
// 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 d = pm.durationMs
|
||||||
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
|
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)
|
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 = {
|
private let appVersion: String = {
|
||||||
let info = Bundle.main.infoDictionary
|
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 -------------------------------------------------------------------------------
|
// ---- settings -------------------------------------------------------------------------------
|
||||||
|
|||||||
+5
-2
@@ -6,8 +6,10 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.1"
|
# iOS needs a plain number for the bundle version; the label is what the app and releases show.
|
||||||
CURRENT_PROJECT_VERSION: "20"
|
MARKETING_VERSION: "1.0"
|
||||||
|
VERSION_LABEL: "1.0 RC 1"
|
||||||
|
CURRENT_PROJECT_VERSION: "21"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
@@ -33,6 +35,7 @@ targets:
|
|||||||
CFBundleDisplayName: Stemify
|
CFBundleDisplayName: Stemify
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
StemifyVersionLabel: $(VERSION_LABEL)
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ cd "$(dirname "$0")/.."
|
|||||||
export PATH=/opt/homebrew/bin:$PATH
|
export PATH=/opt/homebrew/bin:$PATH
|
||||||
B=build; OUT=$B/work; APP=$OUT/Payload/Stemify.app; EXT=$APP/PlugIns/StemifyWidgets.appex
|
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
|
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.
|
# xcodebuild compiles the app, the widget extension, the asset catalog and the App Intents metadata.
|
||||||
xcodegen generate -q
|
xcodegen generate -q
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ yml = open(os.path.join(HERE, "..", "project.yml")).read()
|
|||||||
builds = {}
|
builds = {}
|
||||||
try:
|
try:
|
||||||
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
|
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):
|
except (OSError, ValueError, KeyError, IndexError):
|
||||||
pass
|
pass
|
||||||
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
|
# Tags and IPAs use the label as a slug ("1.0 RC 1" -> 1.0-rc1); AltStore gets the plain bundle
|
||||||
builds[m[0][1]] = m[1][1]
|
# 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 = []
|
versions = []
|
||||||
for rel in get(API + "/releases?limit=50"):
|
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:
|
if not rel["tag_name"].startswith("ios-v") or not ipa:
|
||||||
continue
|
continue
|
||||||
versions.append({
|
versions.append({
|
||||||
"version": v,
|
"version": v.split("-")[0],
|
||||||
|
"marketingVersion": v.replace("-rc", " RC ").replace("-", " ") if "-" in v else v,
|
||||||
"buildVersion": builds.get(v, v),
|
"buildVersion": builds.get(v, v),
|
||||||
"date": rel["published_at"],
|
"date": rel["published_at"],
|
||||||
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
|
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
|
||||||
|
|||||||
+26
-8
@@ -102,12 +102,17 @@ const Wave = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
|
const names = Object.keys(this.peaks), n = this.peaks[names[0]].length;
|
||||||
|
// The envelope only changes with the song or the mix, not every redraw.
|
||||||
|
const gains = names.map(nm => stemGain(nm, names)), gk = gains.join();
|
||||||
|
if (this.env?.peaks !== this.peaks || this.env.gk !== gk) {
|
||||||
const full = new Float32Array(n), mixed = new Float32Array(n);
|
const full = new Float32Array(n), mixed = new Float32Array(n);
|
||||||
for (const nm of names) {
|
names.forEach((nm, k) => {
|
||||||
const g = stemGain(nm, names);
|
const g = gains[k];
|
||||||
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
|
this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; });
|
||||||
|
});
|
||||||
|
this.env = { peaks: this.peaks, gk, full, mixed, max: Math.sqrt(Math.max(...full)) || 1 };
|
||||||
}
|
}
|
||||||
const max = Math.sqrt(Math.max(...full)) || 1;
|
const { full, mixed, max } = this.env;
|
||||||
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
|
const bars = Math.max(20, Math.floor(w / 3)), bw = w / bars;
|
||||||
for (let b = 0; b < bars; b++) {
|
for (let b = 0; b < bars; b++) {
|
||||||
const i = Math.floor(b / bars * n);
|
const i = Math.floor(b / bars * n);
|
||||||
@@ -117,6 +122,15 @@ const Wave = {
|
|||||||
x.fillStyle = b / bars < frac ? this.c.accent : this.c.bar; x.fillRect(b * bw, (h - bh) / 2, bw - 1, bh);
|
x.fillStyle = b / bars < frac ? this.c.accent : this.c.bar; x.fillRect(b * bw, (h - bh) / 2, bw - 1, bh);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Redraws during playback only when the played part has grown by a bar (or a pixel without peaks).
|
||||||
|
follow() {
|
||||||
|
const w = this.canvas.clientWidth, dur = E.duration();
|
||||||
|
const steps = this.peaks ? Math.max(20, Math.floor(w / 3)) : w;
|
||||||
|
const at = dur ? Math.floor(Math.min(1, E.position() / dur) * steps) : 0;
|
||||||
|
if (at === this.at) return;
|
||||||
|
this.at = at;
|
||||||
|
this.draw();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let drag = false;
|
let drag = false;
|
||||||
@@ -387,10 +401,14 @@ document.addEventListener("keydown", ev => {
|
|||||||
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
|
else if (k === "]" || k === "[") { const i = SPEEDS.indexOf(S.speed) + (k === "]" ? 1 : -1); if (SPEEDS[i]) setSpeed(SPEEDS[i]); }
|
||||||
});
|
});
|
||||||
|
|
||||||
(function frame() {
|
// The clock, waveform and lyrics follow playback 5 times a second instead of every frame:
|
||||||
if (E.playing && !E.loading) { $("#t-pos").textContent = fmtTime(E.position()); Wave.draw(); tickLyrics(); }
|
// none of them change faster than that, and a 120 Hz loop keeps phones warm.
|
||||||
requestAnimationFrame(frame);
|
setInterval(() => {
|
||||||
})();
|
if (document.hidden || !E.playing || E.loading) return;
|
||||||
|
const t = fmtTime(E.position()), el = $("#t-pos");
|
||||||
|
if (el.textContent !== t) el.textContent = t;
|
||||||
|
Wave.follow(); tickLyrics();
|
||||||
|
}, 200);
|
||||||
setInterval(() => { if (E.playing) { UI.progress(); saveQueue(); } }, 5000);
|
setInterval(() => { if (E.playing) { UI.progress(); saveQueue(); } }, 5000);
|
||||||
|
|
||||||
on("track", () => { UI.track(); UI.transport(); UI.progress(); Wave.load(Q.cur()); marks(); if (drawer) UI.drawer(); });
|
on("track", () => { UI.track(); UI.transport(); UI.progress(); Wave.load(Q.cur()); marks(); if (drawer) UI.drawer(); });
|
||||||
@@ -1154,5 +1172,5 @@ async function connect() {
|
|||||||
UI.all();
|
UI.all();
|
||||||
route();
|
route();
|
||||||
Live.poll();
|
Live.poll();
|
||||||
setInterval(() => Stems.refresh(), 5 * 60 * 1000);
|
setInterval(() => { if (!document.hidden) Stems.refresh(); }, 5 * 60 * 1000);
|
||||||
})();
|
})();
|
||||||
@@ -87,6 +87,8 @@ export const Live = {
|
|||||||
get busy() { return !!this.current || this.pending.size > 0; },
|
get busy() { return !!this.current || this.pending.size > 0; },
|
||||||
async poll() {
|
async poll() {
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
|
// Hidden tabs don't poll; coming back calls poll() again.
|
||||||
|
if (document.hidden) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(api("/api/queue"), { cache: "no-store", signal: AbortSignal.timeout(8000) });
|
const r = await fetch(api("/api/queue"), { cache: "no-store", signal: AbortSignal.timeout(8000) });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
|
|||||||
Reference in new issue
Block a user