This document details the WebView-based playback system, its architecture, and implementation notes.
YouTube Music uses DRM (Widevine) to protect Premium content. Native playback via AVPlayer is not possible because:
- Bot Detection: YouTube's APIs detect non-browser clients and block them
- DRM: Premium content requires Widevine CDM, only available in WebKit
- User Interaction: YouTube requires a user gesture to start playback
Our solution: A singleton WebView that loads YouTube Music watch pages and plays audio through WebKit's native DRM support.
| Component | File | Purpose |
|---|---|---|
SingletonPlayerWebView |
MiniPlayerWebView.swift |
Manages the one-and-only WebView |
PersistentPlayerView |
MiniPlayerWebView.swift |
SwiftUI wrapper for the WebView |
PlayerService |
PlayerService.swift |
Playback state and control |
AppDelegate |
AppDelegate.swift |
Window lifecycle for background audio |
@MainActor
final class SingletonPlayerWebView {
static let shared = SingletonPlayerWebView()
private(set) var webView: WKWebView?
var currentVideoId: String?
func getWebView(webKitManager:, playerService:) -> WKWebView
func loadVideo(videoId: String)
}Why Singleton?
- Prevents multiple audio streams
- Survives SwiftUI view recreation
- Survives window close/reopen
- Single source of truth for playback
// In a view
playerService.play(videoId: "dQw4w9WgXcQ")This sets:
pendingPlayVideoId= video IDshowMiniPlayer=true(shows toast for user interaction)
MainWindow observes pendingPlayVideoId:
if let videoId = playerService.pendingPlayVideoId {
PersistentPlayerView(videoId: videoId, isExpanded: playerService.showMiniPlayer)
.frame(width: showMiniPlayer ? 160 : 1, height: showMiniPlayer ? 90 : 1)
}PersistentPlayerView either:
- Creates new WebView (first play)
- Reuses existing WebView (subsequent plays)
func makeNSView(context: Context) -> NSView {
let webView = SingletonPlayerWebView.shared.getWebView(...)
// Load if different video
if SingletonPlayerWebView.shared.currentVideoId != videoId {
webView.load(URLRequest(url: watchURL))
}
return container
}JavaScript observer sends state via WKScriptMessageHandler:
bridge.postMessage({
type: 'STATE_UPDATE',
isPlaying: true,
progress: 45,
duration: 210
});Swift receives and updates PlayerService:
func userContentController(_:, didReceive message:) {
playerService.updatePlaybackState(
isPlaying: isPlaying,
progress: progress,
duration: duration
)
}When user plays a different track:
pendingPlayVideoIdchanges- SwiftUI calls
updateNSView(notmakeNSView) SingletonPlayerWebView.loadVideo(videoId:)called- Current audio paused, new URL loaded
func loadVideo(videoId: String) {
guard videoId != currentVideoId else { return }
// Update ID immediately to prevent duplicate loads
currentVideoId = videoId
// Pause current, load new
webView.evaluateJavaScript("document.querySelector('video')?.pause()") { _, _ in
self.webView?.load(URLRequest(url: watchURL))
}
}By default, closing a window destroys its view hierarchy, killing the WebView. We prevent this:
// AppDelegate.swift
extension AppDelegate: NSWindowDelegate {
func windowShouldClose(_ sender: NSWindow) -> Bool {
sender.orderOut(nil) // Hide instead of close
return false // Don't actually close
}
}func applicationShouldTerminateAfterLastWindowClosed(_:) -> Bool {
return false // Keep app running when window hidden
}func applicationShouldHandleReopen(_:, hasVisibleWindows:) -> Bool {
if !hasVisibleWindows {
for window in NSApplication.shared.windows where window.canBecomeMain {
window.makeKeyAndOrderFront(nil)
return true
}
}
return true
}| Action | Result |
|---|---|
| Close window (⌘W) | Window hides, audio continues |
| Click dock icon | Window reappears, same audio |
| Quit app (⌘Q) | App terminates, audio stops |
Injected into every watch page:
(function() {
'use strict';
const bridge = window.webkit.messageHandlers.singletonPlayer;
function waitForPlayerBar() {
const playerBar = document.querySelector('ytmusic-player-bar');
if (playerBar) {
setupObserver(playerBar);
return;
}
setTimeout(waitForPlayerBar, 500);
}
function setupObserver(playerBar) {
const observer = new MutationObserver(sendUpdate);
observer.observe(playerBar, {
attributes: true, characterData: true,
childList: true, subtree: true
});
sendUpdate();
setInterval(sendUpdate, 1000);
}
function sendUpdate() {
const playPauseBtn = document.querySelector('.play-pause-button.ytmusic-player-bar');
const isPlaying = playPauseBtn?.getAttribute('title') === 'Pause';
const progressBar = document.querySelector('#progress-bar');
bridge.postMessage({
type: 'STATE_UPDATE',
isPlaying: isPlaying,
progress: parseInt(progressBar?.getAttribute('value') || '0'),
duration: parseInt(progressBar?.getAttribute('aria-valuemax') || '0')
});
}
waitForPlayerBar();
})();func userContentController(_:, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
body["type"] as? String == "STATE_UPDATE" else { return }
Task { @MainActor in
playerService.updatePlaybackState(
isPlaying: body["isPlaying"] as? Bool ?? false,
progress: Double(body["progress"] as? Int ?? 0),
duration: Double(body["duration"] as? Int ?? 0)
)
}
}A small toast in the bottom-right corner:
| State | Size | Purpose |
|---|---|---|
| Visible | 160×90 | User clicks to interact |
| Hidden | 1×1 | WebView stays in hierarchy |
.frame(
width: playerService.showMiniPlayer ? 160 : 1,
height: playerService.showMiniPlayer ? 90 : 1
)When playback starts, the mini player auto-dismisses:
// In Coordinator
if isPlaying && playerService.showMiniPlayer {
playerService.confirmPlaybackStarted()
}Cause: Multiple WebViews created
Solution: Singleton pattern ensures one WebView
Cause: WebView destroyed with view hierarchy
Solution: windowShouldClose returns false, hides instead
Cause: updateNSView not called
Solution: Pass videoId as parameter to trigger SwiftUI updates
Cause: User interaction required by YouTube
Solution: Mini player toast allows user to click play
We use Safari's user agent to avoid "browser not optimized" warnings:
static let userAgent = """
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/605.1.15 (KHTML, like Gecko) \
Version/17.0 Safari/605.1.15
"""Enable WebView inspector in Debug builds:
#if DEBUG
webView.isInspectable = true
#endifRight-click the mini player → "Inspect Element" to debug JavaScript.
When playing artist mixes (RDEM... playlists), the app supports infinite queue loading:
- Initial Load:
playWithMix()fetches ~50 songs via thenextendpoint - Continuation Token: The API returns a
nextRadioContinuationData.continuationtoken - Auto-Fetch: When ≤10 songs remain in queue,
fetchMoreMixSongsIfNeeded()loads more - Duplicate Filter: New songs are filtered to prevent duplicates
- Repeat: Process continues until no more continuation tokens
| Component | Purpose |
|---|---|
RadioQueueResult |
Holds songs + continuation token |
mixContinuationToken |
Stored in PlayerService |
isFetchingMoreMixSongs |
Prevents concurrent fetches |
fetchMoreMixSongsIfNeeded() |
Triggered on next() and playFromQueue() |
The continuation token is cleared when:
- Playing a regular queue (
playQueue) - Playing song radio (
playWithRadio) - Clearing the queue (
clearQueue)
This prevents infinite fetch from triggering on non-mix playback.
For floating video window functionality, see docs/video.md.
- Queue management (next/previous)
- Infinite mix loading
- Video mode (floating video window)
- Seek support via JavaScript
- Volume control
- Now Playing in Control Center (via WKWebView media session + remote commands)