diff --git a/Core/Services/Player/PlayerService.swift b/Core/Services/Player/PlayerService.swift index ac84789..ac0539b 100644 --- a/Core/Services/Player/PlayerService.swift +++ b/Core/Services/Player/PlayerService.swift @@ -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() + } + + /// Updates the AirPlay connection status from the WebView. + func updateAirPlayStatus(isConnected: Bool, wasRequested: Bool = false) { + self.isAirPlayConnected = isConnected + if wasRequested { + self.airPlayWasRequested = true + } + } + // MARK: - Private Methods /// Legacy method for evaluating player commands - now delegates to SingletonPlayerWebView. diff --git a/Core/Utilities/AccessibilityIdentifiers.swift b/Core/Utilities/AccessibilityIdentifiers.swift index 0a696b4..bf2f829 100644 --- a/Core/Utilities/AccessibilityIdentifiers.swift +++ b/Core/Utilities/AccessibilityIdentifiers.swift @@ -32,6 +32,7 @@ enum AccessibilityID { static let lyricsButton = "playerBar.lyrics" static let queueButton = "playerBar.queue" static let videoButton = "playerBar.video" + static let airplayButton = "playerBar.airplayButton" static let volumeSlider = "playerBar.volumeSlider" static let trackTitle = "playerBar.trackTitle" static let trackArtist = "playerBar.trackArtist" diff --git a/Core/Utilities/DiagnosticsLogger.swift b/Core/Utilities/DiagnosticsLogger.swift index b7f2d5c..46cd839 100644 --- a/Core/Utilities/DiagnosticsLogger.swift +++ b/Core/Utilities/DiagnosticsLogger.swift @@ -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") } diff --git a/Views/macOS/MiniPlayerWebView.swift b/Views/macOS/MiniPlayerWebView.swift index cfd0d15..e9167e4 100644 --- a/Views/macOS/MiniPlayerWebView.swift +++ b/Views/macOS/MiniPlayerWebView.swift @@ -296,6 +296,8 @@ final class SingletonPlayerWebView { } /// 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") @@ -341,10 +343,25 @@ final class SingletonPlayerWebView { func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any], - let type = body["type"] as? String, - type == "STATE_UPDATE" + 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 diff --git a/Views/macOS/PlayerBar.swift b/Views/macOS/PlayerBar.swift index f8df5a4..cffe07b 100644 --- a/Views/macOS/PlayerBar.swift +++ b/Views/macOS/PlayerBar.swift @@ -1,4 +1,3 @@ -import AVKit import SwiftUI // MARK: - PlayerBar @@ -382,8 +381,19 @@ struct PlayerBar: View { self.actionButtons // AirPlay button - AirPlayButton() - .frame(width: 20, height: 20) + Button { + HapticService.toggle() + self.playerService.showAirPlayPicker() + } label: { + Image(systemName: "airplayaudio") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(self.playerService.isAirPlayConnected ? .red : .primary.opacity(0.85)) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.pressable) + .accessibilityIdentifier(AccessibilityID.PlayerBar.airplayButton) + .accessibilityLabel(self.playerService.isAirPlayConnected ? "AirPlay Connected" : "AirPlay") + .disabled(self.playerService.currentTrack == nil) Divider() .frame(height: 20) @@ -538,22 +548,6 @@ struct PlayerBar: View { } } -// MARK: - AirPlayButton - -/// A SwiftUI wrapper for AVRoutePickerView to show AirPlay destinations. -@available(macOS 26.0, *) -struct AirPlayButton: NSViewRepresentable { - func makeNSView(context _: Context) -> AVRoutePickerView { - let routePickerView = AVRoutePickerView() - routePickerView.isRoutePickerButtonBordered = false - return routePickerView - } - - func updateNSView(_: AVRoutePickerView, context _: Context) { - // No updates needed - } -} - @available(macOS 26.0, *) #Preview { PlayerBar() diff --git a/Views/macOS/SingletonPlayerWebView+ObserverScript.swift b/Views/macOS/SingletonPlayerWebView+ObserverScript.swift index c6f9646..bc21909 100644 --- a/Views/macOS/SingletonPlayerWebView+ObserverScript.swift +++ b/Views/macOS/SingletonPlayerWebView+ObserverScript.swift @@ -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 + }); + } + // 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', () => { diff --git a/Views/macOS/SingletonPlayerWebView+PlaybackControls.swift b/Views/macOS/SingletonPlayerWebView+PlaybackControls.swift index a4b5f47..1b1985e 100644 --- a/Views/macOS/SingletonPlayerWebView+PlaybackControls.swift +++ b/Views/macOS/SingletonPlayerWebView+PlaybackControls.swift @@ -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)") + } + } + } + } } diff --git a/docs/adr/0010-airplay-fix.md b/docs/adr/0010-airplay-fix.md new file mode 100644 index 0000000..fb85630 --- /dev/null +++ b/docs/adr/0010-airplay-fix.md @@ -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'; + })(); + """ + 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)") + } + } +} +``` + +### Step 2: Add PlayerService method + +**File:** `Core/Services/Player/PlayerService.swift` + +```swift +/// Show the AirPlay picker for selecting audio output devices. +func showAirPlayPicker() { + SingletonPlayerWebView.shared.showAirPlayPicker() +} +``` + +Note: No `Task` wrapper needed since `PlayerService` is already `@MainActor`. + +### Step 3: Replace AirPlayButton in PlayerBar + +**File:** `Views/macOS/PlayerBar.swift` + +Replace the `AVRoutePickerView`-based `AirPlayButton` with: + +```swift +Button { + HapticService.toggle() + self.playerService.showAirPlayPicker() +} label: { + Image(systemName: "airplayaudio") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.primary.opacity(0.85)) +} +.buttonStyle(.pressable) +.accessibilityIdentifier(AccessibilityID.PlayerBar.airplayButton) +.accessibilityLabel("AirPlay") +.disabled(self.playerService.currentTrack == nil) +``` + +Notes: +- `HapticService.toggle()` matches other PlayerBar buttons +- `self.` prefix per SwiftFormat `--self insert` rule +- Disabled when no track to avoid silent failures (requires video element) + +### Step 4: Add accessibility identifier constant + +**File:** Wherever `AccessibilityID.PlayerBar` is defined (likely `Core/Constants/AccessibilityID.swift` or similar) + +```swift +static let airplayButton = "playerBar.airplayButton" +``` + +### Step 5: Clean up unused code + +- Remove the `AirPlayButton` struct (lines 541-555) +- Remove `import AVKit` from PlayerBar.swift if no longer used + +## Consequences + +### Positive + +- **Actually works** - AirPlay will route WebView audio to selected devices +- **Consistent pattern** - Uses same JavaScript injection approach as other playback controls +- **Native picker** - Shows WebKit's native AirPlay UI which matches system behavior + +### Negative + +- **Availability detection unreliable** - `webkitplaybacktargetavailabilitychanged` may not fire in WKWebView, so button visibility can't be conditional on AirPlay device availability +- **Requires active playback** - Picker needs video element to exist; button is disabled until a track is playing +- **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 + +## Verification + +1. Build the app - ensure no compilation errors +2. Start playing a song +3. Click the AirPlay button +4. Verify native WebKit AirPlay picker appears +5. Select an AirPlay device (Sonos, HomePod, Apple TV) +6. Verify audio routes to selected device + +## Known Limitations + +### AirPlay Connection Lost on Track Change + +**Problem:** When a track changes (skip, song ends), YouTube Music destroys and recreates the `