|
| 1 | +# ADR-0010: Fix AirPlay for WebView-Based Playback |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Implemented (with known limitations) |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +**Issue:** [GitHub Issue #42](https://github.com/sozercan/kaset/issues/42) - AirPlay does not work |
| 10 | + |
| 11 | +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. |
| 12 | + |
| 13 | +### Root Cause |
| 14 | + |
| 15 | +**Architectural mismatch between the AirPlay UI control and the audio playback system:** |
| 16 | + |
| 17 | +1. **Current Implementation**: Uses `AVRoutePickerView` (AVKit) which only controls routing for AVFoundation-based playback (AVPlayer, AVAudioSession) |
| 18 | +2. **Audio Source**: App uses WKWebView to play YouTube Music (required for Widevine DRM per ADR-0001) |
| 19 | +3. **The Disconnect**: `AVRoutePickerView` has no connection to WebKit's audio output |
| 20 | + |
| 21 | +## Decision |
| 22 | + |
| 23 | +Replace `AVRoutePickerView` with WebKit's native AirPlay picker triggered via JavaScript injection using `webkitShowPlaybackTargetPicker()`. |
| 24 | + |
| 25 | +This follows the existing pattern used for play/pause, volume, seek, and other playback controls in `SingletonPlayerWebView+PlaybackControls.swift`. |
| 26 | + |
| 27 | +## Implementation |
| 28 | + |
| 29 | +### Files to Modify |
| 30 | + |
| 31 | +1. **`Views/macOS/SingletonPlayerWebView+PlaybackControls.swift`** - Add `showAirPlayPicker()` method |
| 32 | +2. **`Views/macOS/PlayerBar.swift`** - Replace `AVRoutePickerView` with custom button |
| 33 | +3. **`Core/Services/Player/PlayerService.swift`** - Add `showAirPlayPicker()` method to call through to SingletonPlayerWebView |
| 34 | + |
| 35 | +### Step 1: Add JavaScript injection method |
| 36 | + |
| 37 | +**File:** `Views/macOS/SingletonPlayerWebView+PlaybackControls.swift` |
| 38 | + |
| 39 | +```swift |
| 40 | +/// Show the native AirPlay picker for the WebView's video element. |
| 41 | +func showAirPlayPicker() { |
| 42 | + guard let webView else { return } |
| 43 | + |
| 44 | + let script = """ |
| 45 | + (function() { |
| 46 | + const video = document.querySelector('video'); |
| 47 | + if (video && typeof video.webkitShowPlaybackTargetPicker === 'function') { |
| 48 | + video.webkitShowPlaybackTargetPicker(); |
| 49 | + return 'picker-shown'; |
| 50 | + } |
| 51 | + return 'no-video-or-unsupported'; |
| 52 | + })(); |
| 53 | + """ |
| 54 | + webView.evaluateJavaScript(script) { [weak self] result, error in |
| 55 | + if let error { |
| 56 | + self?.logger.error("showAirPlayPicker error: \(error.localizedDescription)") |
| 57 | + } else if let result = result as? String { |
| 58 | + self?.logger.debug("showAirPlayPicker: \(result)") |
| 59 | + } |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +### Step 2: Add PlayerService method |
| 65 | + |
| 66 | +**File:** `Core/Services/Player/PlayerService.swift` |
| 67 | + |
| 68 | +```swift |
| 69 | +/// Show the AirPlay picker for selecting audio output devices. |
| 70 | +func showAirPlayPicker() { |
| 71 | + SingletonPlayerWebView.shared.showAirPlayPicker() |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +Note: No `Task` wrapper needed since `PlayerService` is already `@MainActor`. |
| 76 | + |
| 77 | +### Step 3: Replace AirPlayButton in PlayerBar |
| 78 | + |
| 79 | +**File:** `Views/macOS/PlayerBar.swift` |
| 80 | + |
| 81 | +Replace the `AVRoutePickerView`-based `AirPlayButton` with: |
| 82 | + |
| 83 | +```swift |
| 84 | +Button { |
| 85 | + HapticService.toggle() |
| 86 | + self.playerService.showAirPlayPicker() |
| 87 | +} label: { |
| 88 | + Image(systemName: "airplayaudio") |
| 89 | + .font(.system(size: 15, weight: .medium)) |
| 90 | + .foregroundStyle(.primary.opacity(0.85)) |
| 91 | +} |
| 92 | +.buttonStyle(.pressable) |
| 93 | +.accessibilityIdentifier(AccessibilityID.PlayerBar.airplayButton) |
| 94 | +.accessibilityLabel("AirPlay") |
| 95 | +.disabled(self.playerService.currentTrack == nil) |
| 96 | +``` |
| 97 | + |
| 98 | +Notes: |
| 99 | +- `HapticService.toggle()` matches other PlayerBar buttons |
| 100 | +- `self.` prefix per SwiftFormat `--self insert` rule |
| 101 | +- Disabled when no track to avoid silent failures (requires video element) |
| 102 | + |
| 103 | +### Step 4: Add accessibility identifier constant |
| 104 | + |
| 105 | +**File:** Wherever `AccessibilityID.PlayerBar` is defined (likely `Core/Constants/AccessibilityID.swift` or similar) |
| 106 | + |
| 107 | +```swift |
| 108 | +static let airplayButton = "playerBar.airplayButton" |
| 109 | +``` |
| 110 | + |
| 111 | +### Step 5: Clean up unused code |
| 112 | + |
| 113 | +- Remove the `AirPlayButton` struct (lines 541-555) |
| 114 | +- Remove `import AVKit` from PlayerBar.swift if no longer used |
| 115 | + |
| 116 | +## Consequences |
| 117 | + |
| 118 | +### Positive |
| 119 | + |
| 120 | +- **Actually works** - AirPlay will route WebView audio to selected devices |
| 121 | +- **Consistent pattern** - Uses same JavaScript injection approach as other playback controls |
| 122 | +- **Native picker** - Shows WebKit's native AirPlay UI which matches system behavior |
| 123 | + |
| 124 | +### Negative |
| 125 | + |
| 126 | +- **Availability detection unreliable** - `webkitplaybacktargetavailabilitychanged` may not fire in WKWebView, so button visibility can't be conditional on AirPlay device availability |
| 127 | +- **Requires active playback** - Picker needs video element to exist; button is disabled until a track is playing |
| 128 | +- **Minor UX change** - Current `AVRoutePickerView` shows devices even without playback (though they don't work). New approach disables button until playback starts, which is more honest but slightly different behavior |
| 129 | + |
| 130 | +## Verification |
| 131 | + |
| 132 | +1. Build the app - ensure no compilation errors |
| 133 | +2. Start playing a song |
| 134 | +3. Click the AirPlay button |
| 135 | +4. Verify native WebKit AirPlay picker appears |
| 136 | +5. Select an AirPlay device (Sonos, HomePod, Apple TV) |
| 137 | +6. Verify audio routes to selected device |
| 138 | + |
| 139 | +## Known Limitations |
| 140 | + |
| 141 | +### AirPlay Connection Lost on Track Change |
| 142 | + |
| 143 | +**Problem:** When a track changes (skip, song ends), YouTube Music destroys and recreates the `<video>` element. This breaks the AirPlay connection because WebKit ties the AirPlay session to the specific video element instance. |
| 144 | + |
| 145 | +**Why This Can't Be Fixed:** WebKit does not provide a programmatic API to connect to an AirPlay device. The user must manually click the AirPlay button and select a device after each track change. There is no way to automatically reconnect to the previously selected device. |
| 146 | + |
| 147 | +### Picker Position |
| 148 | + |
| 149 | +The AirPlay picker's position is determined by the video element's location in the viewport. Since YouTube Music hides the video element (0x0 at position 0,0), the picker appears in the top-left corner. Attempts to reposition the video element via CSS have been unsuccessful due to YouTube's CSS hierarchy. This is a cosmetic issue - the picker still functions correctly. |
| 150 | + |
| 151 | +## References |
| 152 | + |
| 153 | +- [Apple: Adding an AirPlay button to Safari media controls](https://developer.apple.com/documentation/webkitjs/adding_an_airplay_button_to_your_safari_media_controls) |
| 154 | +- [Apple Developer Forums: AirPlay inside a webview](https://developer.apple.com/forums/thread/30179) |
| 155 | +- ADR-0001: WebView-Based Playback |
0 commit comments