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
This commit is contained in:
edde746
2026-08-26 16:53:14 +02:00
parent fce4193f7e
commit c564635ad9
15 changed files with 375 additions and 24 deletions
@@ -32,6 +32,7 @@ import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.ThemeHelper
import com.edde746.plezy.watchnext.WatchNextPlugin
import io.flutter.embedding.android.FlutterActivity
@@ -735,6 +736,7 @@ class MainActivity : FlutterActivity() {
"getTvDetection" -> result.success(getAndroidTvDetection())
"getDeviceName" -> result.success(getDeviceName())
"getPerformanceSignals" -> result.success(getPerformanceSignals())
"getVideoDecodeCapabilities" -> result.success(MediaCodecQuery.hardwareVideoDecodeSupport())
"getBackgroundWorkSignals" -> result.success(
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
)
@@ -7,6 +7,34 @@ import java.util.Locale
/** Canonical decoder lookup and hardware classification for native playback. */
internal object MediaCodecQuery {
/**
* Codecs whose advertised support is decided by hardware: both have a
* software decoder behind them, but a software HEVC or AV1 decode on
* phone/TV-class hardware drops frames.
*/
private val HARDWARE_GATED_VIDEO_MIME_TYPES = mapOf(
"hevc" to "video/hevc",
"av1" to "video/av01"
)
/** Codec name -> whether this device has a hardware decoder for it. */
fun hardwareVideoDecodeSupport(
hardwareMimeTypes: Set<String> = hardwareDecoderMimeTypes()
): Map<String, Boolean> = HARDWARE_GATED_VIDEO_MIME_TYPES.mapValues { (_, mimeType) -> mimeType in hardwareMimeTypes }
/**
* Every MIME type served by a hardware decoder, lowercased. One walk answers
* for all codecs, unlike [findHardwareDecoder], which rescans per lookup.
*/
private fun hardwareDecoderMimeTypes(): Set<String> {
val mimeTypes = HashSet<String>()
for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) {
if (info.isEncoder || !isHardwareAccelerated(info)) continue
for (type in info.supportedTypes) mimeTypes.add(type.lowercase(Locale.ROOT))
}
return mimeTypes
}
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
@@ -1,5 +1,6 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -60,4 +61,18 @@ class MediaCodecQueryTest {
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
}
@Test
fun mapsGatedCodecsToTheMimeTypesDecodersAdvertise() {
assertEquals(
mapOf("hevc" to true, "av1" to true),
MediaCodecQuery.hardwareVideoDecodeSupport(
setOf("video/avc", "video/hevc", "video/av01", "audio/mp4a-latm")
)
)
assertEquals(
mapOf("hevc" to true, "av1" to false),
MediaCodecQuery.hardwareVideoDecodeSupport(setOf("video/avc", "video/hevc"))
)
}
}
+4
View File
@@ -25,6 +25,7 @@
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
@@ -80,6 +81,7 @@
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDecodeCapabilitiesPlugin.swift; sourceTree = "<group>"; };
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -197,6 +199,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -430,6 +433,7 @@
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+4
View File
@@ -38,6 +38,10 @@ import MediaPlayer
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())
}
@@ -0,0 +1,32 @@
import CoreMedia
import Flutter
import VideoToolbox
/// Answers `com.plezy/device`'s `getVideoDecodeCapabilities` on iOS and tvOS.
///
/// Compiled into the iOS and tvOS Runners only — macOS keeps its own sources
/// and is deliberately left unprobed, because desktop CPUs software-decode
/// both codecs in real time and narrowing the profile there would force
/// transcodes for nothing.
class VideoDecodeCapabilitiesPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.plezy/device",
binaryMessenger: registrar.messenger()
)
registrar.addMethodCallDelegate(VideoDecodeCapabilitiesPlugin(), channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getVideoDecodeCapabilities":
result([
"hevc": VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC),
"av1": VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1),
])
default:
// The rest of `com.plezy/device` is Android-only.
result(FlutterMethodNotImplemented)
}
}
}
+6 -2
View File
@@ -34,6 +34,7 @@ import 'screens/profile/pin_entry_dialog.dart';
import 'screens/profile/profile_switch_screen.dart';
import 'services/storage_service.dart';
import 'services/device_performance.dart';
import 'services/video_decode_capabilities.dart';
import 'services/macos_window_service.dart';
import 'services/native_window_service.dart';
import 'services/fullscreen_state_manager.dart';
@@ -911,12 +912,14 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async
});
}
// MainApp reads both synchronous facades during its first build, and both
// have a working sync fallback, so a detection failure is not fatal.
// MainApp reads the first two synchronous facades during its first build
// and the Jellyfin device profile the third at playback negotiation. All
// three have a working sync fallback, so a detection failure is not fatal.
await _optionalGatePhase(StartupPhase.deviceCapabilities, () async {
await (
TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)),
DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)),
VideoDecodeCapabilities.getInstance(),
).wait;
});
@@ -1033,6 +1036,7 @@ Future<void> _logEnvironmentDiagnostics() async {
' [effects: ${DevicePerformance.describeSync()}]',
);
appLogger.i('Display: ${DevicePerformance.describeDisplay()}');
appLogger.i('Video decoders: ${VideoDecodeCapabilities.describeSync()}');
if (Platform.isAndroid) {
appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB');
}
+2
View File
@@ -20,6 +20,7 @@ import '../../services/background_work_diagnostics_service.dart';
import '../../services/device_performance.dart';
import '../../services/log_upload_service.dart';
import '../../services/startup_diagnostics.dart';
import '../../services/video_decode_capabilities.dart';
import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart';
@@ -100,6 +101,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
buffer.writeln('Effects: ${DevicePerformance.describeSync()}');
buffer.writeln('Display: ${DevicePerformance.describeDisplay()}');
buffer.writeln('Video decoders: ${VideoDecodeCapabilities.describeSync()}');
setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight());
}
+1
View File
@@ -78,6 +78,7 @@ import 'scrub_preview_source.dart';
import 'settings_service.dart' show SpecialsOrdering;
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
import 'video_decode_capabilities.dart';
import '../mpv/mpv.dart';
import '../utils/codec_utils.dart';
@@ -9,6 +9,38 @@ bool _canUseJellyfinStaticStreamFallback(Object error) {
return true;
}
/// Video codecs the client accepts in an original file, as a set: a codec
/// missing here makes the server transcode instead of serving the file, which
/// is the right trade when [VideoDecodeCapabilities] reports no hardware
/// decoder. `h265` is Jellyfin's alternate spelling of `hevc` and travels with
/// it; the unconditional entries software-decode cheaply on any device that
/// plays video at all.
String _jellyfinDirectPlayVideoCodecs() {
final hevc = VideoDecodeCapabilities.supportsHevc;
return [
if (hevc) 'hevc',
'h264',
if (hevc) 'h265',
'vp8',
'vp9',
if (VideoDecodeCapabilities.supportsAv1) 'av1',
'mpeg4',
'mpeg2video',
].join(',');
}
/// Video codecs the client accepts as a transcode output, best first — unlike
/// the direct-play list this one is an ordered preference and the server
/// encodes to the first entry. It first rotates codecs the admin has not
/// enabled ("Allow encoding in HEVC/AV1 format", both off by default) to the
/// back, so leading with AV1 costs nothing on a server that will not emit it
/// and gives the better picture at a given bitrate on one that will (#2131).
String _jellyfinTranscodeVideoCodecs() => [
if (VideoDecodeCapabilities.supportsAv1) 'av1',
if (VideoDecodeCapabilities.supportsHevc) 'hevc',
'h264',
].join(',');
mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
// Implemented by _JellyfinBrowseMethods (cross-part call, same pattern as
// _JellyfinImageDownloadMethods' redeclarations).
@@ -755,19 +787,24 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
// so a server with an AV1 hardware encoder could never pick it.
// Every mpv backend already consumes fMP4 HLS — the Plex VOD
// target has shipped it since issue #1859.
//
// Comma-separated codec lists are order-sensitive — first entry
// wins when the server picks an output codec. HEVC is listed
// ahead of H.264 so a server that has "Allow encoding in HEVC
// format" enabled will actually emit HEVC instead of falling
// back to H.264.
'TranscodingProfiles': <Map<String, Object?>>[
const {
{
'Type': 'Video',
'Container': 'mp4',
'Protocol': 'hls',
'VideoCodec': 'hevc,h264',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
'VideoCodec': _jellyfinTranscodeVideoCodecs(),
// Every audio codec Jellyfin can put in an fMP4 segment, so a
// transcode forced by the video stream can still copy the audio
// instead of re-encoding it; AAC leads because it is the only
// entry the server can reliably encode to. Two silent traps:
// the server validates this 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" the way it is for a direct-play profile —
// the server substitutes the source codec, filters it against
// the same fMP4 set, and ships no audio at all for a source it
// cannot carry.
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,dts,truehd',
},
// Track playback transcode target: stereo mp3 over plain http.
// Appended after the video profile so the first-entry-wins
@@ -782,18 +819,20 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
'MaxAudioChannels': '2',
},
],
// Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
// from forcing a transcode for HEVC sources whose container we
// already accept — mpv decodes HEVC natively on every platform
// we ship.
'DirectPlayProfiles': <Map<String, Object?>>[
const {
{
'Type': 'Video',
'Container': 'mp4,mkv,m4v,webm,mov,ts',
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video',
'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts',
'VideoCodec': _jellyfinDirectPlayVideoCodecs(),
// No `AudioCodec`: an omitted list means "any codec" to
// Jellyfin. mpv decodes every audio codec these containers can
// carry and an audio decode is cheap everywhere, so an audio
// stream must never be the reason a file cannot direct-play.
},
// Music containers/codecs mpv plays natively everywhere.
// Music containers/codecs mpv plays natively everywhere. This one
// keeps its `AudioCodec` because Jellyfin falls back to the
// container list for `Type: Audio`, and a multi-container entry is
// not a codec name.
if (audioProfile)
const {
'Type': 'Audio',
@@ -0,0 +1,82 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../utils/async_singleton.dart';
import '../utils/device_channel.dart';
/// Whether this device has a hardware decoder for HEVC and AV1.
///
/// mpv software-decodes both everywhere, so this is not "can we decode it" but
/// "can we decode it without dropping frames". Without a hardware decoder a
/// phone or TV box has to ask the server to transcode instead of taking the
/// original stream, and must not offer AV1 as a transcode target.
///
/// Only Android (`MediaCodecList`) and iOS/tvOS (`VTIsHardwareDecodeSupported`)
/// answer the probe. Desktop deliberately does not implement it: a
/// pre-Kaby-Lake Mac has no hardware HEVC decoder and an M1 no hardware AV1
/// one, yet both software-decode in real time, so narrowing there would force
/// transcodes for nothing. An unanswered or failed probe reports support — the
/// advertised codec list must never narrow on missing data.
///
/// Latched once during the startup device-capabilities phase and read
/// synchronously after, so every negotiation in a session advertises the same
/// codecs.
class VideoDecodeCapabilities {
VideoDecodeCapabilities._();
static final AsyncSingleton<VideoDecodeCapabilities> _singleton = AsyncSingleton();
/// Null until the platform answers, so [describeSync] can tell a measured
/// "yes" from an assumed one. Both are set together or not at all.
bool? _hardwareHevc;
bool? _hardwareAv1;
/// Get the singleton, probing the platform's decoders on first call.
static Future<VideoDecodeCapabilities> getInstance() =>
_singleton.getInstance(VideoDecodeCapabilities._, (instance) => instance._detect());
Future<void> _detect() async {
try {
final result = await deviceChannel.invokeMapMethod<String, dynamic>('getVideoDecodeCapabilities');
if (result == null) return;
_hardwareHevc = result['hevc'] == true;
_hardwareAv1 = result['av1'] == true;
} on MissingPluginException {
// Desktop, or a stale native build — keep advertising both.
} on PlatformException {
// Decoder enumeration failed — keep advertising both.
}
}
/// Whether HEVC should be advertised to a media server. Safe before init.
static bool get supportsHevc => _singleton.instance?._hardwareHevc ?? true;
/// Whether AV1 should be advertised to a media server. Safe before init.
static bool get supportsAv1 => _singleton.instance?._hardwareAv1 ?? true;
/// One-line summary for the startup log and bug-report headers, e.g.
/// `hevc=hw av1=none` on an Apple TV 4K, or `unprobed` on desktop. Answers
/// "did this device really report an AV1 decoder" when a transcode stutters.
static String describeSync() {
final instance = _singleton.instance;
if (instance == null) return 'unknown';
final hevc = instance._hardwareHevc;
final av1 = instance._hardwareAv1;
if (hevc == null || av1 == null) return 'unprobed';
return 'hevc=${hevc ? 'hw' : 'none'} av1=${av1 ? 'hw' : 'none'}';
}
/// Test-only: drop the memoized probe, optionally seeding measured results
/// so the sync accessors can be exercised without a platform channel.
@visibleForTesting
static void debugReset({bool? hardwareHevc, bool? hardwareAv1}) {
if (hardwareHevc == null && hardwareAv1 == null) {
_singleton.debugReset();
return;
}
final instance = _singleton.instance ?? VideoDecodeCapabilities._();
_singleton.debugReset(instance: instance);
instance._hardwareHevc = hardwareHevc;
instance._hardwareAv1 = hardwareAv1;
}
}
+51 -5
View File
@@ -21,6 +21,7 @@ import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/subtitle_preference.dart';
import 'package:plezy/utils/device_identity.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/services/video_decode_capabilities.dart';
import 'package:plezy/services/settings_service.dart';
import '../test_helpers/backend_client_fixtures.dart';
@@ -2361,7 +2362,9 @@ void main() {
expect(profile['DirectPlayProfiles'], isNotEmpty);
final directPlayProfile = (profile['DirectPlayProfiles'] as List<dynamic>).first as Map<String, dynamic>;
expect(directPlayProfile['VideoCodec'], contains('mpeg2video'));
expect(directPlayProfile['AudioCodec'], contains('mp2'));
// An omitted list means "any codec" to Jellyfin, so an audio stream can
// never be what blocks direct play.
expect(directPlayProfile.containsKey('AudioCodec'), isFalse);
expect(profile['TranscodingProfiles'], isNotEmpty);
expect(profile['CodecProfiles'], isEmpty);
const textSubtitleFormats = ['srt', 'ass', 'ssa', 'vtt'];
@@ -2411,10 +2414,53 @@ void main() {
// every platform — the Plex VOD target already ships it.
expect(videoTranscode['Container'], 'mp4');
expect(videoTranscode['Protocol'], 'hls');
// Order-sensitive: the first listed codec wins when the server picks
// an output codec, so HEVC must stay ahead of H.264.
expect(videoTranscode['VideoCodec'], 'hevc,h264');
expect(videoTranscode['AudioCodec'], 'aac,mp3,ac3,eac3,flac,opus');
// Order-sensitive: the first listed codec wins when the server picks an
// output codec, and the server rotates codecs its admin has not enabled
// to the back. AV1 leads so an AV1-capable server actually emits it.
expect(videoTranscode['VideoCodec'], 'av1,hevc,h264');
// Every audio codec Jellyfin can put in an fMP4 segment, so a video
// transcode can still copy the audio track.
expect(videoTranscode['AudioCodec'], 'aac,mp3,ac3,eac3,flac,opus,dts,truehd');
// The server echoes both lists into the transcode URL and validates them
// against this regex, so one character over 40 fails the playlist
// request with HTTP 400 — and `*` is not a wildcard.
for (final key in ['VideoCodec', 'AudioCodec']) {
final list = videoTranscode[key] as String;
expect(list.length, lessThanOrEqualTo(40), reason: '$key is too long for the server to accept: $list');
expect(list, matches(RegExp(r'^[a-zA-Z0-9\-\._,|]{0,40}$')), reason: '$key has characters the server rejects');
}
});
test('the hardware decoder probe narrows both video codec lists', () async {
addTearDown(VideoDecodeCapabilities.debugReset);
Future<Map<String, dynamic>> profileFor({required bool hevc, required bool av1}) async {
VideoDecodeCapabilities.debugReset(hardwareHevc: hevc, hardwareAv1: av1);
String? capturedBody;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
capturedBody = request.body;
return jsonResponse({'MediaSources': []});
}),
);
addTearDown(scoped.close);
await scoped.getPlaybackInfo('item-1');
return (jsonDecode(capturedBody!) as Map<String, dynamic>)['DeviceProfile'] as Map<String, dynamic>;
}
String codecs(Map<String, dynamic> profile, String profileKey) =>
((profile[profileKey] as List<dynamic>).first as Map<String, dynamic>)['VideoCodec'] as String;
// Advertising a codec the device can only software-decode is what makes
// the server hand the stream over instead of transcoding, so a missing
// hardware decoder has to drop it from both lists.
final noDecoders = await profileFor(hevc: false, av1: false);
expect(codecs(noDecoders, 'TranscodingProfiles'), 'h264');
expect(codecs(noDecoders, 'DirectPlayProfiles'), 'h264,vp8,vp9,mpeg4,mpeg2video');
final hevcOnly = await profileFor(hevc: true, av1: false);
expect(codecs(hevcOnly, 'TranscodingProfiles'), 'hevc,h264');
expect(codecs(hevcOnly, 'DirectPlayProfiles'), 'hevc,h264,h265,vp8,vp9,mpeg4,mpeg2video');
});
test('image subtitle formats are declared Embed-only so a transcode burns them in', () async {
@@ -0,0 +1,85 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/video_decode_capabilities.dart';
import 'package:plezy/utils/device_channel.dart';
void main() {
final binding = TestWidgetsFlutterBinding.ensureInitialized();
/// Answers `com.plezy/device` with [reply] and records each call. A null
/// [reply] leaves the method unimplemented, which is what desktop and a
/// stale native build look like.
List<String> stubProbe(Object? reply) {
final calls = <String>[];
binding.defaultBinaryMessenger.setMockMethodCallHandler(deviceChannel, (call) async {
calls.add(call.method);
if (reply == null) throw MissingPluginException();
if (reply is PlatformException) throw reply;
return reply;
});
addTearDown(() => binding.defaultBinaryMessenger.setMockMethodCallHandler(deviceChannel, null));
return calls;
}
setUp(() {
VideoDecodeCapabilities.debugReset();
addTearDown(VideoDecodeCapabilities.debugReset);
});
test('advertises both codecs before the probe has run', () {
expect(VideoDecodeCapabilities.supportsHevc, isTrue);
expect(VideoDecodeCapabilities.supportsAv1, isTrue);
expect(VideoDecodeCapabilities.describeSync(), 'unknown');
});
test('reports the decoders the platform found', () async {
stubProbe(<String, Object?>{'hevc': true, 'av1': false});
await VideoDecodeCapabilities.getInstance();
expect(VideoDecodeCapabilities.supportsHevc, isTrue);
expect(VideoDecodeCapabilities.supportsAv1, isFalse);
expect(VideoDecodeCapabilities.describeSync(), 'hevc=hw av1=none');
});
test('a missing codec entry counts as no hardware decoder', () async {
stubProbe(<String, Object?>{'hevc': true});
await VideoDecodeCapabilities.getInstance();
expect(VideoDecodeCapabilities.supportsAv1, isFalse);
});
// Desktop implements no probe at all, so the advertised codec list must stay
// wide there: those platforms software-decode both codecs in real time.
test('an unanswered probe keeps both codecs advertised', () async {
stubProbe(null);
await VideoDecodeCapabilities.getInstance();
expect(VideoDecodeCapabilities.supportsHevc, isTrue);
expect(VideoDecodeCapabilities.supportsAv1, isTrue);
expect(VideoDecodeCapabilities.describeSync(), 'unprobed');
});
test('a failed probe keeps both codecs advertised', () async {
stubProbe(PlatformException(code: 'error', message: 'MediaCodecList exploded'));
await VideoDecodeCapabilities.getInstance();
expect(VideoDecodeCapabilities.supportsHevc, isTrue);
expect(VideoDecodeCapabilities.supportsAv1, isTrue);
expect(VideoDecodeCapabilities.describeSync(), 'unprobed');
});
test('the probe runs once and is shared by concurrent callers', () async {
final calls = stubProbe(<String, Object?>{'hevc': false, 'av1': false});
final instances = await (VideoDecodeCapabilities.getInstance(), VideoDecodeCapabilities.getInstance()).wait;
expect(calls, ['getVideoDecodeCapabilities']);
expect(identical(instances.$1, instances.$2), isTrue);
expect(VideoDecodeCapabilities.supportsHevc, isFalse);
expect(VideoDecodeCapabilities.describeSync(), 'hevc=none av1=none');
});
}
+4
View File
@@ -50,6 +50,7 @@
E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; };
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
F9C95D532B6F6B72D187217D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF0E0C298AEDE34FF7C8BFD5 /* Pods_RunnerTests.framework */; };
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -146,6 +147,7 @@
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoDecodeCapabilitiesPlugin.swift; path = ../ios/Runner/VideoDecodeCapabilitiesPlugin.swift; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -303,6 +305,7 @@
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
B1D51A6A2F00110000000011 /* VideoDecodeCapabilitiesPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
BF0B7DFE378943E9D7865D18 /* MpvPlayer */,
D58B788B11920D95BC7D750C /* Plugins */,
@@ -622,6 +625,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B1D51A6A2F00110000000010 /* VideoDecodeCapabilitiesPlugin.swift in Sources */,
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */,
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */,
+3
View File
@@ -173,6 +173,9 @@ import wakelock_plus
if let r = self.registrar(forPlugin: "SystemShelfPlugin") {
SystemShelfPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "VideoDecodeCapabilitiesPlugin") {
VideoDecodeCapabilitiesPlugin.register(with: r)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}