Files
plezy/tvos/Runner/AppDelegate.swift
T
edde746 6c14049e95 fix(tvos): make EAC3 playback conform to Dolby's guidance
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.

Audio session, per the guide's sequence:

- Adopt the long-form playback profile in one atomic call at app launch
  and activate the session there. The SDK only accepts that policy with
  category Playback, a Default/MoviePlayback/SpokenAudio mode and no
  options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
  system resolves it. Apple only resolves it for CarPlay and AirPlay, so
  an unresolved value means unknown, never "not Dolby".

Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):

- Add a sample-buffer arm. It reads the asset with AVAssetReader at
  outputSettings nil and hands the untouched compressed buffers and the
  untouched format description straight to the renderer, with a variant
  that rebuilds the description the way playback builds it. Every
  existing mode went through AVPlayer, so nothing exercised the path
  playback actually uses; this is what tells us whether the renderer or
  our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
  resolves the rendering mode and the supported channel layouts, so it is
  what makes those observations reachable at all, and the AVPlayer arms
  now allow external playback so every arm can be compared on the same
  destination.
- Add a session-mode toggle for the one profile difference between the
  guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
  descriptions, the magic cookie and the renderer status, and release the
  session on stop so a failed run cannot contaminate the next one.

Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.

This does not yet fix #1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
2026-07-26 20:42:58 +02:00

198 lines
6.4 KiB
Swift

import Flutter
import UIKit
import AVFoundation
import universal_gamepad
import os_media_controls
import wakelock_plus
@objc class PlezyFlutterViewController: FlutterViewController {
private lazy var tvRemoteChannel = FlutterBasicMessageChannel(
name: "flutter/gamepadtouchevent",
binaryMessenger: binaryMessenger,
codec: FlutterJSONMessageCodec.sharedInstance()
)
override var canBecomeFirstResponder: Bool {
true
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
override func viewWillDisappear(_ animated: Bool) {
resignFirstResponder()
super.viewWillDisappear(animated)
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if handlePlayPausePress(presses) {
return
}
super.pressesBegan(presses, with: event)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if containsPlayPausePress(presses) {
return
}
super.pressesEnded(presses, with: event)
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if containsPlayPausePress(presses) {
return
}
super.pressesCancelled(presses, with: event)
}
override func remoteControlReceived(with event: UIEvent?) {
guard let event = event else {
super.remoteControlReceived(with: event)
return
}
let subtype = event.subtype
print("PlezyTvRemote: remote control event subtype=\(remoteControlSubtypeName(subtype))")
switch subtype {
case .remoteControlPlay, .remoteControlPause, .remoteControlTogglePlayPause:
sendPlayPauseEvent(source: "remote_control", detail: remoteControlSubtypeName(subtype))
default:
super.remoteControlReceived(with: event)
}
}
private func handlePlayPausePress(_ presses: Set<UIPress>) -> Bool {
guard containsPlayPausePress(presses) else { return false }
sendPlayPauseEvent(source: "presses", detail: "playPause")
return true
}
private func containsPlayPausePress(_ presses: Set<UIPress>) -> Bool {
presses.contains { press in
press.type == .playPause
}
}
private func sendPlayPauseEvent(source: String, detail: String) {
print("PlezyTvRemote: intercepted play/pause source=\(source) detail=\(detail)")
tvRemoteChannel.sendMessage(["type": "play_pause", "source": source, "detail": detail])
}
private func remoteControlSubtypeName(_ subtype: UIEvent.EventSubtype) -> String {
switch subtype {
case .remoteControlPlay:
return "remoteControlPlay"
case .remoteControlPause:
return "remoteControlPause"
case .remoteControlTogglePlayPause:
return "remoteControlTogglePlayPause"
case .remoteControlStop:
return "remoteControlStop"
case .remoteControlNextTrack:
return "remoteControlNextTrack"
case .remoteControlPreviousTrack:
return "remoteControlPreviousTrack"
case .remoteControlBeginSeekingForward:
return "remoteControlBeginSeekingForward"
case .remoteControlEndSeekingForward:
return "remoteControlEndSeekingForward"
case .remoteControlBeginSeekingBackward:
return "remoteControlBeginSeekingBackward"
case .remoteControlEndSeekingBackward:
return "remoteControlEndSeekingBackward"
default:
return "unknown(\(subtype.rawValue))"
}
}
}
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Dolby's sequence diagram prescribes exactly this at app launch: the
// long-form playback profile, then activation, so the session is eligible
// for the system's Dolby decode/render path and its rendering capabilities
// can be read before any content is chosen. The mpv AVFoundation audio
// output reconfigures and re-activates the same shared session at playback
// start; this establishes the launch-time state the guide expects.
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playback, mode: .default, policy: .longFormAudio, options: [])
try session.setActive(true)
} catch {
print("Failed to configure long-form audio session: \(error)")
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
} catch {
print("Failed to configure audio session: \(error)")
}
}
application.beginReceivingRemoteControlEvents()
if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL {
_ = SystemShelfPlugin.handleOpenURL(url)
}
if let r = self.registrar(forPlugin: "SharedPreferencesPlugin") {
SharedPreferencesPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "MpvPlayerPlugin") {
MpvPlayerPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "AtmosProbePlugin") {
AtmosProbePlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "PackageInfoPlusPlugin") {
PackageInfoPlusPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "PathProviderPlugin") {
PathProviderPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "GamepadPlugin") {
GamepadPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "DeviceInfoPlusPlugin") {
DeviceInfoPlusPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "ConnectivityPlusPlugin") {
ConnectivityPlusPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "OsMediaControlsPlugin") {
OsMediaControlsPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "WakelockPlusPlugin") {
WakelockPlusPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "SystemShelfPlugin") {
SystemShelfPlugin.register(with: r)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(
_ application: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if SystemShelfPlugin.handleOpenURL(url) {
return true
}
return super.application(application, open: url, options: options)
}
}