Skip to content

Commit 6cf7068

Browse files
Endiruslanclaude
andauthored
Fix media-key next replaying same song in background (#319)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 795b115 commit 6cf7068

3 files changed

Lines changed: 56 additions & 0 deletions

File tree

Sources/Kaset/AppDelegate.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
9292
DiagnosticsLogger.player.info("System woke from sleep, wasPlayingBeforeSleep: \(self.wasPlayingBeforeSleep)")
9393
}
9494

95+
func applicationDidResignActive(_: Notification) {
96+
// WebKit freezes the page's requestAnimationFrame loop in the background, so the
97+
// media-key override (nexttrack/previoustrack) is no longer re-applied and YouTube
98+
// can reclaim it. Drive re-assertion from a native timer instead.
99+
SingletonPlayerWebView.shared.beginBackgroundMediaControlReassertion()
100+
}
101+
95102
func applicationDidBecomeActive(_: Notification) {
103+
// Foreground: the page's requestAnimationFrame loop resumes ownership of the
104+
// override. Stop the native timer and re-assert once immediately.
105+
SingletonPlayerWebView.shared.endBackgroundMediaControlReassertion()
106+
SingletonPlayerWebView.shared.reassertMediaControlOverride()
96107
// When app becomes active (e.g., dock icon clicked), ensure main window is visible.
97108
// This handles the case where video window is visible but main window is hidden.
98109
if self.isSwitchedToMiniPlayer {

Sources/Kaset/Views/MiniPlayerWebView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ final class SingletonPlayerWebView {
249249
var mediaControlUsesNextPrev: Bool
250250
var playbackAudioQuality: SettingsManager.PlaybackAudioQuality
251251

252+
/// Native timer that re-asserts the media-key override while backgrounded.
253+
/// See `beginBackgroundMediaControlReassertion()`.
254+
var mediaControlReassertTimer: Timer?
255+
252256
/// Tracks if lyrics high-frequency polling should be active
253257
/// Used to restore polling after full-page navigation
254258
var isLyricsPollActive = false

Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
// MARK: - SingletonPlayerWebView Media Controls
24

35
extension SingletonPlayerWebView {
@@ -15,6 +17,45 @@ extension SingletonPlayerWebView {
1517
Self.mediaControlStyleBootstrapScript(useNextPrev: self.mediaControlUsesNextPrev)
1618
}
1719

20+
/// Re-asserts Kaset's `nexttrack`/`previoustrack` media-session override immediately.
21+
///
22+
/// YouTube Music periodically re-registers its own handlers. In `nextPreviousTrack`
23+
/// mode the page keeps ownership via a `requestAnimationFrame` re-apply loop — but
24+
/// WebKit freezes `requestAnimationFrame` while the app is backgrounded, so the
25+
/// override is lost and a media-key press falls through to YouTube (which jumps to its
26+
/// own recommendation; queue-drift recovery then restarts the current song from 0).
27+
/// Driving the re-apply from a native timer keeps the override alive in the background.
28+
func reassertMediaControlOverride() {
29+
guard self.mediaControlUsesNextPrev, let webView = self.webView else { return }
30+
webView.evaluateJavaScript(
31+
"if (typeof window.__kasetRefreshMediaControlStyle === 'function') { window.__kasetRefreshMediaControlStyle(); }",
32+
completionHandler: nil
33+
)
34+
}
35+
36+
/// Starts a native timer that re-asserts the media-key override while the app is
37+
/// backgrounded. Native run-loop timers keep firing in the background (active audio
38+
/// playback prevents App Nap), unlike the page's frozen `requestAnimationFrame` loop.
39+
func beginBackgroundMediaControlReassertion() {
40+
guard self.mediaControlUsesNextPrev else { return }
41+
self.reassertMediaControlOverride()
42+
guard self.mediaControlReassertTimer == nil else { return }
43+
let timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] (_: Timer) in
44+
MainActor.assumeIsolated {
45+
self?.reassertMediaControlOverride()
46+
}
47+
}
48+
timer.tolerance = 0.5
49+
self.mediaControlReassertTimer = timer
50+
}
51+
52+
/// Stops the background re-assertion timer. The page's `requestAnimationFrame` loop
53+
/// resumes ownership once the app is foreground again.
54+
func endBackgroundMediaControlReassertion() {
55+
self.mediaControlReassertTimer?.invalidate()
56+
self.mediaControlReassertTimer = nil
57+
}
58+
1859
static func mediaControlStyleBootstrapScript(useNextPrev: Bool) -> String {
1960
let jsBoolean = useNextPrev ? "true" : "false"
2061
return """

0 commit comments

Comments
 (0)