Files
plezy/ios/Runner/AppDelegate.swift
edde746 c564635ad9 fix(jellyfin): advertise video codecs from a native hardware-decode probe
A device with no hardware HEVC decoder could still be handed an HEVC
transcode: the device profile advertised a fixed codec list that assumed
every device decodes everything. Prepending AV1 to reach the AV1 encoders
issue #2131 asks for would have made that worse - an Apple TV 4K and every
iPhone before the A17 Pro have no AV1 decoder at all.

Probe the platform instead. Android enumerates MediaCodecList for a
hardware decoder and iOS/tvOS ask VideoToolbox, both feeding one latched
VideoDecodeCapabilities that the device profile reads when it builds its
codec lists. Desktop deliberately answers nothing: a pre-Kaby-Lake Mac has
no hardware HEVC decoder and an M1 no AV1 one, yet both software-decode in
real time, so narrowing there would force transcodes for nothing. An
unanswered or failed probe advertises everything, so the list never
narrows on missing data.

The transcode target becomes av1,hevc,h264 filtered by the probe. Leading
with AV1 is safe because the server rotates codecs its admin has not
enabled ("Allow encoding in HEVC/AV1 format", both off by default) to the
back before picking one, so it costs nothing on a server that will not
emit AV1.

Audio now accepts everything the path can carry. The direct-play profile
drops its AudioCodec list entirely - an omitted list means "any codec" to
Jellyfin - so an audio stream can no longer be what blocks direct play.
The transcode target lists every codec Jellyfin can put in an fMP4
segment, so a video-only transcode copies DTS or TrueHD instead of
re-encoding it. Two limits bound that string: the server validates it
against ^[a-zA-Z0-9\-\._,|]{0,40}$ when it echoes the list into the
transcode URL, so alac does not fit and * is not a wildcard; and omitting
the key is not "accept everything" here the way it is for direct play,
because the server substitutes the source codec and then ships no audio at
all for a source fMP4 cannot carry.

close #2131
2026-08-26 16:53:14 +02:00

160 lines
5.6 KiB
Swift

import Flutter
import UIKit
import AVFoundation
import MediaPlayer
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var deviceAdjustmentChannel: FlutterMethodChannel?
private var originalBrightness: CGFloat?
private var volumeView: MPVolumeView?
private weak var volumeSlider: UISlider?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Configure the non-mixing session; activate it only when playback starts.
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
} catch {
print("Failed to configure audio session: \(error)")
}
application.beginReceivingRemoteControlEvents()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
MpvPlayerPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "VideoDecodeCapabilitiesPlugin") {
VideoDecodeCapabilitiesPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "DeviceAdjustmentChannel") {
registerDeviceAdjustmentChannel(messenger: registrar.messenger())
}
}
private func registerDeviceAdjustmentChannel(messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: "com.plezy/device_adjustment", binaryMessenger: messenger)
channel.setMethodCallHandler { [weak self] call, result in
DispatchQueue.main.async {
self?.handleDeviceAdjustmentCall(call, result: result)
}
}
deviceAdjustmentChannel = channel
}
private func handleDeviceAdjustmentCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getBrightness":
result(Double(UIScreen.main.brightness))
case "setBrightness":
guard let value = normalizedArgument(call.arguments, result: result) else { return }
if originalBrightness == nil {
originalBrightness = UIScreen.main.brightness
}
UIScreen.main.brightness = CGFloat(value)
result(nil)
case "restoreBrightness":
if let originalBrightness = originalBrightness {
UIScreen.main.brightness = originalBrightness
self.originalBrightness = nil
}
result(nil)
case "getMediaVolume":
result(Double(AVAudioSession.sharedInstance().outputVolume))
case "setMediaVolume":
guard let value = normalizedArgument(call.arguments, result: result) else { return }
if setMediaVolume(value) {
result(nil)
} else {
result(
FlutterError(code: "DEVICE_ADJUSTMENT_UNAVAILABLE", message: "System volume slider unavailable", details: nil)
)
}
default:
result(FlutterMethodNotImplemented)
}
}
private func normalizedArgument(_ arguments: Any?, result: @escaping FlutterResult) -> Double? {
guard let value = arguments as? NSNumber else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a numeric value", details: nil))
return nil
}
let doubleValue = value.doubleValue
guard doubleValue.isFinite else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a finite numeric value", details: nil))
return nil
}
return min(1.0, max(0.0, doubleValue))
}
private func setMediaVolume(_ value: Double) -> Bool {
guard let slider = ensureVolumeSlider() else { return false }
slider.setValue(Float(value), animated: false)
slider.sendActions(for: .valueChanged)
slider.sendActions(for: .touchUpInside)
return true
}
private func ensureVolumeSlider() -> UISlider? {
if let volumeSlider = volumeSlider { return volumeSlider }
let volumeView = self.volumeView ?? MPVolumeView(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
volumeView.showsRouteButton = false
volumeView.showsVolumeSlider = true
volumeView.alpha = 0.01
volumeView.isUserInteractionEnabled = false
if volumeView.superview == nil {
activeWindow?.addSubview(volumeView)
}
volumeView.layoutIfNeeded()
self.volumeView = volumeView
let slider = findVolumeSlider(in: volumeView)
volumeSlider = slider
return slider
}
private func findVolumeSlider(in view: UIView) -> UISlider? {
if let slider = view as? UISlider { return slider }
for subview in view.subviews {
if let slider = findVolumeSlider(in: subview) { return slider }
}
return nil
}
private var activeWindow: UIWindow? {
if #available(iOS 13.0, *) {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
if let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) {
return keyWindow
}
}
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene, let window = windowScene.windows.first else { continue }
return window
}
return nil
}
return UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first
}
}