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
+138 -31

No files matched your search

+3
View File
@@ -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.
+1
View File
@@ -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.
+7
View File
@@ -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() }
+2
View File
@@ -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 ------------------------------------------------------------------------
+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 { 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"
+15 -4
View File
@@ -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()
} }
+13 -4
View File
@@ -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 {
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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)
} }
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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]
+2 -1
View File
@@ -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
+8 -4
View File
@@ -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
View File
@@ -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);
})(); })();
+2
View File
@@ -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) {