diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a519ec..0a6cb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,6 @@ 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-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. diff --git a/ios/Stemify/App/StemifyApp.swift b/ios/Stemify/App/StemifyApp.swift index 90cbd6e..e021400 100644 --- a/ios/Stemify/App/StemifyApp.swift +++ b/ios/Stemify/App/StemifyApp.swift @@ -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. diff --git a/ios/Stemify/App/WidgetBridge.swift b/ios/Stemify/App/WidgetBridge.swift index b2fac1a..581efed 100644 --- a/ios/Stemify/App/WidgetBridge.swift +++ b/ios/Stemify/App/WidgetBridge.swift @@ -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 { diff --git a/ios/Stemify/CarPlay/CarPlayScene.swift b/ios/Stemify/CarPlay/CarPlayScene.swift index 9a29a0a..7eb22ee 100644 --- a/ios/Stemify/CarPlay/CarPlayScene.swift +++ b/ios/Stemify/CarPlay/CarPlayScene.swift @@ -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 ------------------------------------------------------------------------ diff --git a/ios/Stemify/Core/AppActivity.swift b/ios/Stemify/Core/AppActivity.swift new file mode 100644 index 0000000..b31f647 --- /dev/null +++ b/ios/Stemify/Core/AppActivity.swift @@ -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] = [] + + 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] 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) in + let now = lock.with { () -> Bool in + if phone || car { return true } + waiters.append(c) + return false + } + if now { c.resume() } + } + } +} diff --git a/ios/Stemify/Core/Log.swift b/ios/Stemify/Core/Log.swift index 4447ac5..c62c006 100644 --- a/ios/Stemify/Core/Log.swift +++ b/ios/Stemify/Core/Log.swift @@ -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" diff --git a/ios/Stemify/Engine/StemDecoder.swift b/ios/Stemify/Engine/StemDecoder.swift index d7bf44b..63c616c 100644 --- a/ios/Stemify/Engine/StemDecoder.swift +++ b/ios/Stemify/Engine/StemDecoder.swift @@ -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, _ 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() } diff --git a/ios/Stemify/Engine/StemEngine.swift b/ios/Stemify/Engine/StemEngine.swift index 3fbbb14..b738c11 100644 --- a/ios/Stemify/Engine/StemEngine.swift +++ b/ios/Stemify/Engine/StemEngine.swift @@ -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 { diff --git a/ios/Stemify/Plex/JellyfinClient.swift b/ios/Stemify/Plex/JellyfinClient.swift index 08dd9dd..cea50e2 100644 --- a/ios/Stemify/Plex/JellyfinClient.swift +++ b/ios/Stemify/Plex/JellyfinClient.swift @@ -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 diff --git a/ios/Stemify/Stems/StemServer.swift b/ios/Stemify/Stems/StemServer.swift index 04c299e..5f34971 100644 --- a/ios/Stemify/Stems/StemServer.swift +++ b/ios/Stemify/Stems/StemServer.swift @@ -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) } diff --git a/ios/Stemify/UI/Components/Chrome.swift b/ios/Stemify/UI/Components/Chrome.swift index e03ebb3..bf0537f 100644 --- a/ios/Stemify/UI/Components/Chrome.swift +++ b/ios/Stemify/UI/Components/Chrome.swift @@ -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 diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 8825d78..4e90b2c 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -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) diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 38704fe..d98898b 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -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 ------------------------------------------------------------------------------- diff --git a/ios/project.yml b/ios/project.yml index 6f76917..ecc51b7 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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] diff --git a/ios/scripts/build-ipa.sh b/ios/scripts/build-ipa.sh index 16a4d6c..acaa617 100755 --- a/ios/scripts/build-ipa.sh +++ b/ios/scripts/build-ipa.sh @@ -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 diff --git a/ios/scripts/make-source.py b/ios/scripts/make-source.py index a3f5dd9..a9ba461 100755 --- a/ios/scripts/make-source.py +++ b/ios/scripts/make-source.py @@ -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}", diff --git a/web/app.js b/web/app.js index 6082b6d..78c885d 100644 --- a/web/app.js +++ b/web/app.js @@ -102,12 +102,17 @@ const Wave = { return; } const names = Object.keys(this.peaks), n = this.peaks[names[0]].length; - const full = new Float32Array(n), mixed = new Float32Array(n); - for (const nm of names) { - const g = stemGain(nm, names); - this.peaks[nm].forEach((p, i) => { const v = p / 1000; full[i] += v * v; mixed[i] += v * v * g * g; }); + // 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); + names.forEach((nm, k) => { + 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.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; for (let b = 0; b < bars; b++) { 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); } }, + // 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; @@ -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]); } }); -(function frame() { - if (E.playing && !E.loading) { $("#t-pos").textContent = fmtTime(E.position()); Wave.draw(); tickLyrics(); } - requestAnimationFrame(frame); -})(); +// The clock, waveform and lyrics follow playback 5 times a second instead of every frame: +// none of them change faster than that, and a 120 Hz loop keeps phones warm. +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); 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(); route(); Live.poll(); - setInterval(() => Stems.refresh(), 5 * 60 * 1000); + setInterval(() => { if (!document.hidden) Stems.refresh(); }, 5 * 60 * 1000); })(); diff --git a/web/stems.js b/web/stems.js index 6920bad..10b50c2 100644 --- a/web/stems.js +++ b/web/stems.js @@ -87,6 +87,8 @@ export const Live = { get busy() { return !!this.current || this.pending.size > 0; }, async poll() { clearTimeout(this.timer); + // Hidden tabs don't poll; coming back calls poll() again. + if (document.hidden) return; try { const r = await fetch(api("/api/queue"), { cache: "no-store", signal: AbortSignal.timeout(8000) }); if (r.ok) {