-
Notifications
You must be signed in to change notification settings - Fork 27
Expand file tree
/
Copy pathMiniPlayerWebView.swift
More file actions
478 lines (390 loc) · 18.3 KB
/
MiniPlayerWebView.swift
File metadata and controls
478 lines (390 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
import SwiftUI
import WebKit
// MARK: - MiniPlayerWebView
/// A visible WebView that displays the YouTube Music player.
/// This is required because YouTube Music won't initialize the video player
/// without user interaction - autoplay is blocked in hidden WebViews.
/// Uses SingletonPlayerWebView for the actual WebView instance.
struct MiniPlayerWebView: NSViewRepresentable {
@Environment(WebKitManager.self) private var webKitManager
@Environment(PlayerService.self) private var playerService
/// The video ID to play.
let videoId: String
/// Callback for player state changes.
var onStateChange: ((PlayerState) -> Void)?
/// Callback for metadata updates (title, artist, duration).
var onMetadataChange: ((String, String, Double) -> Void)?
enum PlayerState {
case loading
case playing
case paused
case ended
case error(String)
}
func makeCoordinator() -> Coordinator {
Coordinator(onStateChange: self.onStateChange, onMetadataChange: self.onMetadataChange)
}
func makeNSView(context: Context) -> NSView {
let container = NSView(frame: .zero)
container.wantsLayer = true
// Get or create the singleton WebView
let webView = SingletonPlayerWebView.shared.getWebView(
webKitManager: self.webKitManager,
playerService: self.playerService
)
// Remove existing handler if present to avoid duplicates, then add fresh one
// This handles the case where makeNSView is called multiple times
let contentController = webView.configuration.userContentController
contentController.removeScriptMessageHandler(forName: "miniPlayer")
contentController.add(context.coordinator, name: "miniPlayer")
// Ensure WebView is in this container
SingletonPlayerWebView.shared.ensureInHierarchy(container: container)
// Load the video if needed
SingletonPlayerWebView.shared.loadVideo(videoId: self.videoId)
return container
}
func updateNSView(_ container: NSView, context _: Context) {
// Update WebView frame if needed
SingletonPlayerWebView.shared.ensureInHierarchy(container: container)
}
static func dismantleNSView(_: NSView, coordinator _: Coordinator) {
// WebView is managed by SingletonPlayerWebView.shared - it persists
// Remove the message handler to avoid duplicate handlers
SingletonPlayerWebView.shared.webView?.configuration.userContentController
.removeScriptMessageHandler(forName: "miniPlayer")
}
// MARK: - Observer Script
/// Script that observes the YouTube Music player bar and sends updates
private static var observerScript: String {
"""
(function() {
'use strict';
const bridge = window.webkit.messageHandlers.miniPlayer;
function log(msg) {
console.log('[MiniPlayer] ' + msg);
}
// Wait for the player bar to appear and observe it
function waitForPlayerBar() {
const playerBar = document.querySelector('ytmusic-player-bar');
if (playerBar) {
log('Player bar found, setting up observer');
setupObserver(playerBar);
return;
}
setTimeout(waitForPlayerBar, 500);
}
function setupObserver(playerBar) {
const observer = new MutationObserver(function(mutations) {
sendUpdate();
});
observer.observe(playerBar, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
});
// Send initial update
sendUpdate();
// Also send periodic updates
setInterval(sendUpdate, 1000);
}
function sendUpdate() {
try {
const titleEl = document.querySelector('.ytmusic-player-bar.title');
const artistEl = document.querySelector('.ytmusic-player-bar.byline');
const progressBar = document.querySelector('#progress-bar');
const playPauseBtn = document.querySelector('.play-pause-button.ytmusic-player-bar');
const title = titleEl ? titleEl.textContent : '';
const artist = artistEl ? artistEl.textContent : '';
const progress = progressBar ? parseInt(progressBar.getAttribute('value') || '0') : 0;
const duration = progressBar ? parseInt(progressBar.getAttribute('aria-valuemax') || '0') : 0;
// Check if playing by looking at the button title
const isPlaying = playPauseBtn ?
playPauseBtn.getAttribute('title') === 'Pause' ||
playPauseBtn.getAttribute('aria-label') === 'Pause' : false;
bridge.postMessage({
type: 'STATE_UPDATE',
title: title,
artist: artist,
progress: progress,
duration: duration,
isPlaying: isPlaying
});
} catch (e) {
log('Error sending update: ' + e);
}
}
// Start waiting
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForPlayerBar);
} else {
waitForPlayerBar();
}
})();
"""
}
// MARK: - Coordinator
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var onStateChange: ((PlayerState) -> Void)?
var onMetadataChange: ((String, String, Double) -> Void)?
init(
onStateChange: ((PlayerState) -> Void)?,
onMetadataChange: ((String, String, Double) -> Void)?
) {
self.onStateChange = onStateChange
self.onMetadataChange = onMetadataChange
}
func webView(_: WKWebView, didFinish _: WKNavigation!) {
// Page loaded
}
func webView(_: WKWebView, didFail _: WKNavigation!, withError error: Error) {
self.onStateChange?(.error(error.localizedDescription))
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// WebView content process crashed - attempt recovery by reloading
DiagnosticsLogger.player.error("MiniPlayer WebView content process terminated, attempting reload")
self.onStateChange?(.error("Player crashed, reloading..."))
webView.reload()
}
func userContentController(
_: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String
else { return }
if type == "STATE_UPDATE" {
let title = body["title"] as? String ?? ""
let artist = body["artist"] as? String ?? ""
let duration = body["duration"] as? Double ?? 0
let isPlaying = body["isPlaying"] as? Bool ?? false
if !title.isEmpty {
self.onMetadataChange?(title, artist, duration)
}
self.onStateChange?(isPlaying ? .playing : .paused)
}
}
}
}
// MARK: - SingletonPlayerWebView
/// Manages a single WebView instance for the entire app lifetime.
/// This ensures there's only ever ONE WebView playing audio.
///
/// Extensions provide:
/// - Playback controls (SingletonPlayerWebView+PlaybackControls.swift)
/// - Video mode CSS injection (SingletonPlayerWebView+VideoMode.swift)
/// - Observer script (SingletonPlayerWebView+ObserverScript.swift)
@MainActor
final class SingletonPlayerWebView {
static let shared = SingletonPlayerWebView()
private(set) var webView: WKWebView?
var currentVideoId: String?
var coordinator: Coordinator?
let logger = DiagnosticsLogger.player
/// Current display mode for the WebView.
enum DisplayMode {
case hidden // 1x1 for audio-only
case miniPlayer // 160x90 toast
case video // Full size in video window
}
var displayMode: DisplayMode = .hidden
private init() {}
/// Get or create the singleton WebView.
func getWebView(
webKitManager: WebKitManager,
playerService: PlayerService
) -> WKWebView {
if let existing = webView {
return existing
}
self.logger.info("Creating singleton WebView")
// Create coordinator
self.coordinator = Coordinator(playerService: playerService)
let configuration = webKitManager.createWebViewConfiguration()
// Add script message handler
configuration.userContentController.add(self.coordinator!, name: "singletonPlayer")
// Note: We do NOT inject a static volume init script here because the volume
// may change between WebView creation and page loads. Instead, we:
// 1. Set __kasetTargetVolume in loadVideo() before loading a new page
// 2. Update it in didFinish after each page load completes
// This ensures we always use the CURRENT volume, not a stale value.
// Inject observer script (at document end)
let script = WKUserScript(
source: Self.observerScript,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
configuration.userContentController.addUserScript(script)
let newWebView = WKWebView(frame: .zero, configuration: configuration)
newWebView.navigationDelegate = self.coordinator
newWebView.customUserAgent = WebKitManager.userAgent
#if DEBUG
newWebView.isInspectable = true
#endif
self.webView = newWebView
return newWebView
}
/// Ensures the WebView is in the given container's view hierarchy.
func ensureInHierarchy(container: NSView) {
guard let webView, webView.superview !== container else { return }
webView.removeFromSuperview()
container.addSubview(webView)
// Use autoresizing to match container size (consistent with waitForValidBoundsAndInject)
webView.translatesAutoresizingMaskIntoConstraints = true
webView.frame = container.bounds
webView.autoresizingMask = [.width, .height]
// Note: Don't inject CSS here - updateDisplayMode() handles it after layout completes
}
/// Load a video, stopping any currently playing audio first.
/// Note: This uses full page navigation which destroys the video element.
/// AirPlay connections will be lost but the auto-reconnect picker will appear.
func loadVideo(videoId: String) {
guard let webView else {
self.logger.error("loadVideo called but webView is nil")
return
}
let previousVideoId = self.currentVideoId
guard videoId != previousVideoId else {
self.logger.debug("Video \(videoId) already loaded, skipping")
return
}
self.logger.info("Loading video: \(videoId) (was: \(previousVideoId ?? "none"))")
// Update currentVideoId immediately to prevent duplicate loads
self.currentVideoId = videoId
// Get current volume from PlayerService via coordinator
let currentVolume = self.coordinator?.playerService.volume ?? 1.0
self.logger.info("Will apply volume \(currentVolume) after page load")
// Stop current playback first, then load new video
let urlToLoad = URL(string: "https://music.youtube.com/watch?v=\(videoId)")!
webView.evaluateJavaScript("document.querySelector('video')?.pause()") { [weak self] _, _ in
guard let self, let webView = self.webView else { return }
// Set target volume BEFORE loading so it's ready when video element appears
let setTargetScript = "window.__kasetTargetVolume = \(currentVolume);"
webView.evaluateJavaScript(setTargetScript, completionHandler: nil)
webView.load(URLRequest(url: urlToLoad))
}
}
// MARK: - Coordinator
final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
let playerService: PlayerService
init(playerService: PlayerService) {
self.playerService = playerService
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String
else { return }
// Handle AirPlay status updates
if type == "AIRPLAY_STATUS" {
let isConnected = body["isConnected"] as? Bool ?? false
let wasRequested = body["wasRequested"] as? Bool ?? false
Task { @MainActor in
self.playerService.updateAirPlayStatus(
isConnected: isConnected,
wasRequested: wasRequested
)
}
return
}
guard type == "STATE_UPDATE" else { return }
let isPlaying = body["isPlaying"] as? Bool ?? false
let progress = body["progress"] as? Int ?? 0
let duration = body["duration"] as? Int ?? 0
let title = body["title"] as? String ?? ""
let artist = body["artist"] as? String ?? ""
let thumbnailUrl = body["thumbnailUrl"] as? String ?? ""
let trackChanged = body["trackChanged"] as? Bool ?? false
let likeStatusString = body["likeStatus"] as? String ?? "INDIFFERENT"
let hasVideo = body["hasVideo"] as? Bool ?? false
// Parse like status
let likeStatus: LikeStatus = switch likeStatusString {
case "LIKE":
.like
case "DISLIKE":
.dislike
default:
.indifferent
}
Task { @MainActor in
self.playerService.updatePlaybackState(
isPlaying: isPlaying,
progress: Double(progress),
duration: Double(duration)
)
// Update video availability
self.playerService.updateVideoAvailability(hasVideo: hasVideo)
// Update like status only when track changes (initial state)
if trackChanged {
self.playerService.updateLikeStatus(likeStatus)
}
// Update track metadata if track changed
if trackChanged, !title.isEmpty {
self.playerService.updateTrackMetadata(
title: title,
artist: artist,
thumbnailUrl: thumbnailUrl
)
// Close video window on track change, but skip during grace period
// (grace period prevents false positives during initial video mode setup)
// Note: trackChanged detection uses title/artist comparison from the observer script
if self.playerService.showVideo, !self.playerService.isVideoGracePeriodActive {
DiagnosticsLogger.player.info(
"trackChanged to '\(title)' while video shown - closing video window")
self.playerService.showVideo = false
}
}
}
}
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
DiagnosticsLogger.player.info(
"Singleton WebView finished loading: \(webView.url?.absoluteString ?? "nil")")
// Apply the current volume when page finishes loading
// This is critical because YouTube may set its own default volume
let savedVolume = self.playerService.volume
let applyVolumeScript = """
(function() {
// Set target volume for enforcement
window.__kasetTargetVolume = \(savedVolume);
// Set flag to prevent enforcement from reverting our change
window.__kasetIsSettingVolume = true;
// Apply to video element if it exists
const video = document.querySelector('video');
if (video) {
video.volume = \(savedVolume);
}
// Clear flag after a moment
setTimeout(() => { window.__kasetIsSettingVolume = false; }, 100);
return video ? 'applied' : 'no-video-yet';
})();
"""
webView.evaluateJavaScript(applyVolumeScript) { result, error in
if let error {
DiagnosticsLogger.player.error(
"Failed to apply saved volume \(savedVolume): \(error.localizedDescription)"
)
} else if let resultString = result as? String {
DiagnosticsLogger.player.debug("Volume apply result: \(resultString)")
}
}
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
// WebView content process crashed - attempt recovery
DiagnosticsLogger.player.error("Singleton WebView content process terminated, attempting recovery")
// Get the current video ID before reloading
let currentVideoId = SingletonPlayerWebView.shared.currentVideoId
// Reload the WebView
webView.reload()
// If we had a video playing, reload it after a brief delay
if let videoId = currentVideoId {
Task { @MainActor in
try? await Task.sleep(for: .seconds(1))
// Reset currentVideoId to force reload
SingletonPlayerWebView.shared.currentVideoId = nil
SingletonPlayerWebView.shared.loadVideo(videoId: videoId)
}
}
}
}
}