diff --git a/internal/airplay/client.go b/internal/airplay/client.go index 89caa35..a358415 100644 --- a/internal/airplay/client.go +++ b/internal/airplay/client.go @@ -42,6 +42,7 @@ type ReceiverInfo struct { PI string `plist:"pi"` MacAddress string `plist:"macAddress"` Displays []DisplayInfo `plist:"displays"` + hasPTPInfo bool } // AirPlay receiver status flags used to choose one authentication prompt. @@ -281,7 +282,7 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { } return keys }()) - for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats"} { + for _, key := range []string{"audioFormats", "audioLatencies", "displays", "features", "statusFlags", "initialVolume", "volumeControlType", "keepAliveSendStatsAsBody", "supportedAudioFormatsExtended", "supportedFormats", "PTPInfo"} { if v, ok := fullInfo[key]; ok { dbg("[INFO] %s: %+v", key, v) } @@ -292,6 +293,9 @@ func (c *AirPlayClient) GetInfo() (*ReceiverInfo, error) { if _, err := plist.Unmarshal(resp, &info); err != nil { return nil, fmt.Errorf("decode info plist: %w", err) } + if _, ok := fullInfo["PTPInfo"]; ok { + info.hasPTPInfo = true + } c.info = &info return &info, nil } diff --git a/internal/airplay/mirror.go b/internal/airplay/mirror.go index ff7f83f..d832570 100644 --- a/internal/airplay/mirror.go +++ b/internal/airplay/mirror.go @@ -135,6 +135,10 @@ type MirrorSession struct { } func selectAudioSecurityMode(encrypted bool) audioSecurityMode { + // Encrypted pair-verify sessions (Apple and third-party HAP) encrypt audio + // with a stream key advertised as shk. FairPlay ekey is a separate path and + // is not available on TVs that omit FPSAP. The SETUP *shape* (controlPort + // vs streamConnections) is chosen later from usesModernSessionSetup(). if encrypted { return audioSecurityChaCha } @@ -155,6 +159,17 @@ func timingProtocolForSession(modern bool) string { return timingProtocolNTP } +func (i *ReceiverInfo) advertisesPTP() bool { + return i != nil && i.hasPTPInfo +} + +func timingProtocolForClient(c *AirPlayClient, modern bool) string { + if modern || c != nil && c.info.advertisesPTP() { + return timingProtocolPTP + } + return timingProtocolNTP +} + func (c *AirPlayClient) usesModernSessionSetup() bool { return c.encrypted && c.info != nil && c.info.usesModernPairing() } @@ -240,7 +255,7 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig senderName := pairingClientName() modernSession := c.usesModernSessionSetup() sourceVersion := sourceVersionForSession(modernSession) - timingProtocol := timingProtocolForSession(modernSession) + timingProtocol := timingProtocolForClient(c, modernSession) var clock *mediaClock if timingProtocol == timingProtocolPTP { clock = &mediaClock{} @@ -475,13 +490,19 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig audioStreamDesc["redundantAudio"] = int64(2) } - // Modern HAP receivers look for shk on the audio stream descriptor. - modernAudio := audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 + // Modern Apple SETUP replaces controlPort with streamConnections. + // Third-party HAP TVs accepted PTP + controlPort; they still need shk or + // they silently drop plaintext ALAC. + modernAudio := modernSession && audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 if modernAudio { addModernScreenAudioStreamFields(audioStreamDesc, audioChaChaKey, audioControlLPort) dbg("[SETUP] audio stream descriptor includes shk (%d bytes)", len(audioChaChaKey)) } else { audioStreamDesc["controlPort"] = int64(audioControlLPort) + if audioMode == audioSecurityChaCha && len(audioChaChaKey) == 32 { + audioStreamDesc["shk"] = audioChaChaKey + dbg("[SETUP] audio stream descriptor includes shk (%d bytes) with legacy controlPort", len(audioChaChaKey)) + } } var audioSetupPlist map[string]interface{} if modernControlSetup { @@ -517,7 +538,9 @@ func (c *AirPlayClient) setupMirrorSession(ctx context.Context, cfg StreamConfig skipRecord, _ = audioResp["skipRecord"].(bool) if timingProtocol == timingProtocolPTP { if err := clock.configureFromSetup(audioResp, audioRespHeaders, audioRespReceivedAt); err != nil { - return nil, fmt.Errorf("configure PTP media clock: %w", err) + // Third-party TVs advertise PTPInfo but often omit Apple clock + // headers. Keep the session; frames fall back to local time. + dbg("[PTP] %v; using local timestamps", err) } } receiverEventPort = plistInt(audioResp["eventPort"]) diff --git a/internal/airplay/mirror_clock_test.go b/internal/airplay/mirror_clock_test.go index 593d17c..eefa978 100644 --- a/internal/airplay/mirror_clock_test.go +++ b/internal/airplay/mirror_clock_test.go @@ -218,6 +218,20 @@ func TestTimingProtocolForSession(t *testing.T) { } } +func TestTimingProtocolForClientUsesAdvertisedPTP(t *testing.T) { + legacy := &AirPlayClient{info: &ReceiverInfo{}} + if got := timingProtocolForClient(legacy, false); got != timingProtocolNTP { + t.Fatalf("legacy without PTPInfo = %q, want NTP", got) + } + ptpTV := &AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}} + if got := timingProtocolForClient(ptpTV, false); got != timingProtocolPTP { + t.Fatalf("third-party with PTPInfo = %q, want PTP", got) + } + if got := timingProtocolForClient(&AirPlayClient{info: &ReceiverInfo{hasPTPInfo: true}}, true); got != timingProtocolPTP { + t.Fatalf("modern + PTPInfo = %q, want PTP", got) + } +} + func TestModernSessionSetupRequiresFirstPartyProfile(t *testing.T) { const rokuFeatures = uint64(0x38bcf46007f8ad0) diff --git a/internal/airplay/mirror_setup_test.go b/internal/airplay/mirror_setup_test.go index a66ac5f..1a7e43d 100644 --- a/internal/airplay/mirror_setup_test.go +++ b/internal/airplay/mirror_setup_test.go @@ -22,6 +22,15 @@ type rtspTestRequest struct { headers map[string]string } +func TestSelectAudioSecurityMode(t *testing.T) { + if got := selectAudioSecurityMode(false); got != audioSecurityLegacyAES { + t.Fatalf("plaintext session audio mode = %v, want AES/legacy", got) + } + if got := selectAudioSecurityMode(true); got != audioSecurityChaCha { + t.Fatalf("encrypted HAP session audio mode = %v, want ChaCha", got) + } +} + func TestSourceVersionForSession(t *testing.T) { if got := sourceVersionForSession(false); got != legacyAirPlaySourceVersion { t.Fatalf("legacy source version = %q, want %q", got, legacyAirPlaySourceVersion)