Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Core/Services/Player/PlayerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +128 to +133
Copy link

Copilot AI Jan 24, 2026

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).

Suggested change
/// 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

Copilot uses AI. Check for mistakes.
// MARK: - Internal Properties (for extensions)

let logger = DiagnosticsLogger.player
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding test coverage for the new AirPlay functionality. The PlayerServiceTests file shows comprehensive testing of other PlayerService methods. Tests could verify that showAirPlayPicker() sets airPlayWasRequested to true, and that updateAirPlayStatus() correctly updates the isAirPlayConnected property.

Copilot uses AI. Check for mistakes.

/// 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
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateAirPlayStatus method lacks logging for status changes, which is inconsistent with similar update methods in this file. For example, updateVideoAvailability() logs changes at line 453. Consider adding logging to track when AirPlay connections are established or lost, using either DiagnosticsLogger.player or the newly added DiagnosticsLogger.airplay.

Copilot uses AI. Check for mistakes.

// MARK: - Private Methods

/// Legacy method for evaluating player commands - now delegates to SingletonPlayerWebView.
Expand Down
1 change: 1 addition & 0 deletions Core/Utilities/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions Core/Utilities/DiagnosticsLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new AirPlay logger is defined but never used in the codebase. Consider adding logging to the AirPlay-related methods for consistency with other parts of the codebase. For example, log when AirPlay status changes in updateAirPlayStatus() method in PlayerService.swift, similar to how updateVideoAvailability() logs status changes at line 453 of PlayerService.swift.

Suggested change
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)")
}

Copilot uses AI. Check for mistakes.
}
21 changes: 19 additions & 2 deletions Views/macOS/MiniPlayerWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
32 changes: 13 additions & 19 deletions Views/macOS/PlayerBar.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import AVKit
import SwiftUI

// MARK: - PlayerBar
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions Views/macOS/SingletonPlayerWebView+ObserverScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic at line 74 checks if both window.__kasetAirPlayRequested AND window.__kasetAirPlayConnected are true, but at this point we've just checked that initialWireless is false (line 65). This means we're in the 'else if' branch because the video is NOT wirelessly connected. However, the condition checks if __kasetAirPlayConnected is true, which seems contradictory. This appears to be checking for a disconnect scenario when a new video element is created while AirPlay was previously connected, but the logic could be clearer. Consider refactoring this to be more explicit about the disconnect detection scenario.

Copilot uses AI. Check for mistakes.

// 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', () => {
Expand Down
34 changes: 34 additions & 0 deletions Views/macOS/SingletonPlayerWebView+PlaybackControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completionHandler is set to nil, but the ADR document at lines 54-60 shows the expected implementation with error and result handling. For consistency with other playback control methods in this file (e.g., playPause() at line 22, next() at line 68), consider adding a completion handler that logs errors and debug results using the logger.

Copilot uses AI. Check for mistakes.
})();
"""
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)")
}
}
}
}
}
155 changes: 155 additions & 0 deletions docs/adr/0010-airplay-fix.md
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
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a discrepancy between the ADR documentation and the actual implementation. The ADR at line 51 shows the return value as 'no-video-or-unsupported', but the actual implementation splits this into two separate return values: 'no-video' (line 163) and 'unsupported' (line 164). While the actual implementation is more precise, the documentation should be updated to match.

Suggested change
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 uses AI. Check for mistakes.
})();
"""
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)")
}
}
Comment on lines +54 to +60
Copy link

Copilot AI Jan 24, 2026

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
}
```

### 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 `<video>` element. This breaks the AirPlay connection because WebKit ties the AirPlay session to the specific video element instance.

**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.

### Picker Position

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.

## References

- [Apple: Adding an AirPlay button to Safari media controls](https://developer.apple.com/documentation/webkitjs/adding_an_airplay_button_to_your_safari_media_controls)
- [Apple Developer Forums: AirPlay inside a webview](https://developer.apple.com/forums/thread/30179)
- ADR-0001: WebView-Based Playback
Loading