-
Notifications
You must be signed in to change notification settings - Fork 28
fix: implement AirPlay support via WebKit's native picker #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,6 +125,12 @@ final class PlayerService: NSObject, PlayerServiceProtocol { | |
| /// the detection can be unreliable when video mode CSS is active. | ||
| var showVideo: Bool = false | ||
|
|
||
| /// Whether AirPlay is currently connected (playing to a wireless target). | ||
| private(set) var isAirPlayConnected: Bool = false | ||
|
|
||
| /// Whether the user has requested AirPlay this session (for persistence across track changes). | ||
| private(set) var airPlayWasRequested: Bool = false | ||
|
|
||
| // MARK: - Internal Properties (for extensions) | ||
|
|
||
| let logger = DiagnosticsLogger.player | ||
|
|
@@ -694,6 +700,20 @@ final class PlayerService: NSObject, PlayerServiceProtocol { | |
| self.duration = 0 | ||
| } | ||
|
|
||
| /// Show the AirPlay picker for selecting audio output devices. | ||
| func showAirPlayPicker() { | ||
| self.airPlayWasRequested = true | ||
| SingletonPlayerWebView.shared.showAirPlayPicker() | ||
| } | ||
|
Comment on lines
+703
to
+707
|
||
|
|
||
| /// Updates the AirPlay connection status from the WebView. | ||
| func updateAirPlayStatus(isConnected: Bool, wasRequested: Bool = false) { | ||
| self.isAirPlayConnected = isConnected | ||
| if wasRequested { | ||
| self.airPlayWasRequested = true | ||
| } | ||
| } | ||
|
Comment on lines
+709
to
+715
|
||
|
|
||
| // MARK: - Private Methods | ||
|
|
||
| /// Legacy method for evaluating player commands - now delegates to SingletonPlayerWebView. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,4 +38,7 @@ enum DiagnosticsLogger { | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Logger for AppleScript scripting events. | ||||||||||||||||||||||||
| static let scripting = Logger(subsystem: "com.sertacozercan.Kaset", category: "Scripting") | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /// Logger for AirPlay-related events. | ||||||||||||||||||||||||
| static let airplay = Logger(subsystem: "com.sertacozercan.Kaset", category: "AirPlay") | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
| static let airplay = Logger(subsystem: "com.sertacozercan.Kaset", category: "AirPlay") | |
| static let airplay = Logger(subsystem: "com.sertacozercan.Kaset", category: "AirPlay") | |
| /// Logs a change in AirPlay status, for use by AirPlay-related services. | |
| /// | |
| /// - Parameters: | |
| /// - oldStatus: The previous AirPlay status description. | |
| /// - newStatus: The new AirPlay status description. | |
| static func logAirPlayStatusChange(from oldStatus: String, to newStatus: String) { | |
| airplay.info("AirPlay status changed from \(oldStatus, privacy: .public) to \(newStatus, privacy: .public)") | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,40 @@ extension SingletonPlayerWebView { | |
| video.addEventListener('waiting', () => sendUpdate()); // Buffer state | ||
| video.addEventListener('seeked', () => sendUpdate()); // Seek completed | ||
|
|
||
| // AirPlay state tracking | ||
| video.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => { | ||
| const isWireless = video.webkitCurrentPlaybackTargetIsWireless; | ||
| const wasConnected = window.__kasetAirPlayConnected; | ||
| window.__kasetAirPlayConnected = isWireless; | ||
|
|
||
| bridge.postMessage({ | ||
| type: 'AIRPLAY_STATUS', | ||
| isConnected: isWireless, | ||
| wasConnected: wasConnected, | ||
| wasRequested: window.__kasetAirPlayRequested || false | ||
| }); | ||
| }); | ||
|
|
||
| // Check initial AirPlay state | ||
| const initialWireless = video.webkitCurrentPlaybackTargetIsWireless; | ||
| if (initialWireless) { | ||
| window.__kasetAirPlayConnected = true; | ||
| bridge.postMessage({ | ||
| type: 'AIRPLAY_STATUS', | ||
| isConnected: true, | ||
| wasConnected: false, | ||
| wasRequested: window.__kasetAirPlayRequested || false | ||
| }); | ||
| } else if (window.__kasetAirPlayRequested && window.__kasetAirPlayConnected) { | ||
| window.__kasetAirPlayConnected = false; | ||
| bridge.postMessage({ | ||
| type: 'AIRPLAY_STATUS', | ||
| isConnected: false, | ||
| wasConnected: true, | ||
| wasRequested: true | ||
| }); | ||
| } | ||
|
Comment on lines
+74
to
+82
|
||
|
|
||
| // Volume enforcement: listen for external volume changes with debounce | ||
| // This catches YouTube's attempts to change volume and reverts to our target | ||
| video.addEventListener('volumechange', () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -152,4 +152,38 @@ extension SingletonPlayerWebView { | |
| } | ||
| } | ||
| } | ||
|
|
||
| /// Show the native AirPlay picker for the WebView's video element. | ||
| func showAirPlayPicker() { | ||
| guard let webView else { | ||
| DiagnosticsLogger.airplay.warning("showAirPlayPicker called but webView is nil") | ||
| return | ||
| } | ||
|
|
||
| let script = """ | ||
| (function() { | ||
| const video = document.querySelector('video'); | ||
| if (!video) return 'no-video'; | ||
| if (typeof video.webkitShowPlaybackTargetPicker !== 'function') return 'unsupported'; | ||
|
|
||
| window.__kasetAirPlayRequested = true; | ||
| video.webkitShowPlaybackTargetPicker(); | ||
| return 'picker-shown'; | ||
|
||
| })(); | ||
| """ | ||
| webView.evaluateJavaScript(script) { result, error in | ||
| if let error { | ||
| DiagnosticsLogger.airplay.error("showAirPlayPicker error: \(error.localizedDescription)") | ||
| } else if let status = result as? String { | ||
| switch status { | ||
| case "no-video": | ||
| DiagnosticsLogger.airplay.warning("showAirPlayPicker: no video element available") | ||
| case "unsupported": | ||
| DiagnosticsLogger.airplay.warning("showAirPlayPicker: webkitShowPlaybackTargetPicker not supported") | ||
| default: | ||
| DiagnosticsLogger.airplay.debug("showAirPlayPicker: \(status)") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,155 @@ | ||||||||||||||||||||||||||||
| # ADR-0010: Fix AirPlay for WebView-Based Playback | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Status | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Implemented (with known limitations) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Context | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **Issue:** [GitHub Issue #42](https://github.com/sozercan/kaset/issues/42) - AirPlay does not work | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The in-app AirPlay button shows available devices and allows selection, but audio continues playing from the Mac instead of the selected AirPlay device. System-wide AirPlay settings work correctly. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Root Cause | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **Architectural mismatch between the AirPlay UI control and the audio playback system:** | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 1. **Current Implementation**: Uses `AVRoutePickerView` (AVKit) which only controls routing for AVFoundation-based playback (AVPlayer, AVAudioSession) | ||||||||||||||||||||||||||||
| 2. **Audio Source**: App uses WKWebView to play YouTube Music (required for Widevine DRM per ADR-0001) | ||||||||||||||||||||||||||||
| 3. **The Disconnect**: `AVRoutePickerView` has no connection to WebKit's audio output | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Decision | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Replace `AVRoutePickerView` with WebKit's native AirPlay picker triggered via JavaScript injection using `webkitShowPlaybackTargetPicker()`. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| This follows the existing pattern used for play/pause, volume, seek, and other playback controls in `SingletonPlayerWebView+PlaybackControls.swift`. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Implementation | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Files to Modify | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 1. **`Views/macOS/SingletonPlayerWebView+PlaybackControls.swift`** - Add `showAirPlayPicker()` method | ||||||||||||||||||||||||||||
| 2. **`Views/macOS/PlayerBar.swift`** - Replace `AVRoutePickerView` with custom button | ||||||||||||||||||||||||||||
| 3. **`Core/Services/Player/PlayerService.swift`** - Add `showAirPlayPicker()` method to call through to SingletonPlayerWebView | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Step 1: Add JavaScript injection method | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **File:** `Views/macOS/SingletonPlayerWebView+PlaybackControls.swift` | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ```swift | ||||||||||||||||||||||||||||
| /// Show the native AirPlay picker for the WebView's video element. | ||||||||||||||||||||||||||||
| func showAirPlayPicker() { | ||||||||||||||||||||||||||||
| guard let webView else { return } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let script = """ | ||||||||||||||||||||||||||||
| (function() { | ||||||||||||||||||||||||||||
| const video = document.querySelector('video'); | ||||||||||||||||||||||||||||
| if (video && typeof video.webkitShowPlaybackTargetPicker === 'function') { | ||||||||||||||||||||||||||||
| video.webkitShowPlaybackTargetPicker(); | ||||||||||||||||||||||||||||
| return 'picker-shown'; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return 'no-video-or-unsupported'; | ||||||||||||||||||||||||||||
|
Comment on lines
+47
to
+51
|
||||||||||||||||||||||||||||
| if (video && typeof video.webkitShowPlaybackTargetPicker === 'function') { | |
| video.webkitShowPlaybackTargetPicker(); | |
| return 'picker-shown'; | |
| } | |
| return 'no-video-or-unsupported'; | |
| if (!video) { | |
| return 'no-video'; | |
| } | |
| if (typeof video.webkitShowPlaybackTargetPicker === 'function') { | |
| video.webkitShowPlaybackTargetPicker(); | |
| return 'picker-shown'; | |
| } | |
| return 'unsupported'; |
Copilot
AI
Jan 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ADR documentation shows error and result handling with logging (lines 54-60), but the actual implementation uses completionHandler: nil (line 171). The documentation should be updated to reflect the actual implementation, or the implementation should add the error handling as documented.
| webView.evaluateJavaScript(script) { [weak self] result, error in | |
| if let error { | |
| self?.logger.error("showAirPlayPicker error: \(error.localizedDescription)") | |
| } else if let result = result as? String { | |
| self?.logger.debug("showAirPlayPicker: \(result)") | |
| } | |
| } | |
| webView.evaluateJavaScript(script, completionHandler: nil) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The properties isAirPlayConnected and airPlayWasRequested are defined but never read anywhere in the codebase. If these properties are intended for future use or debugging, consider adding comments explaining their purpose. Otherwise, consider whether they're needed at all, or if they should be used to provide visual feedback in the UI (e.g., showing different icons when AirPlay is connected).