From 598eec63b0ebac14964f9fc03c690d539c860b3a Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Mon, 18 May 2026 18:59:14 +0200 Subject: [PATCH] snap --- app/src/main/assets/codecs.json | 34 ++ .../androidcast/AndroidCastApplication.java | 3 + .../foxx/androidcast/CastSettingsBinder.java | 4 +- .../androidcast/media/CodecNegotiator.java | 30 +- .../media/CodecPriorityCatalog.java | 353 ++++++++++++++++++ .../androidcast/media/CodecPriorityId.java | 96 +++++ .../com/foxx/androidcast/media/CodecTier.java | 14 + .../foxx/androidcast/network/CastSession.java | 2 +- .../foxx/androidcast/sender/CodecCatalog.java | 206 ++++++++-- 9 files changed, 700 insertions(+), 42 deletions(-) create mode 100644 app/src/main/assets/codecs.json create mode 100644 app/src/main/java/com/foxx/androidcast/media/CodecPriorityCatalog.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/CodecPriorityId.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/CodecTier.java diff --git a/app/src/main/assets/codecs.json b/app/src/main/assets/codecs.json new file mode 100644 index 0000000..5336190 --- /dev/null +++ b/app/src/main/assets/codecs.json @@ -0,0 +1,34 @@ +{ + "_comment": "Optional overrides for codec priority (lower total score = higher priority). Tier bases: HW=0, SW=500, passthrough/raw=1000.", + "video": { + "h264": { + "priority_override_score": 0 + }, + "h265": { + "priority_override_score": 0 + }, + "vp8": { + "priority_override_score": 0 + }, + "vp9": { + "priority_override_score": 0 + }, + "passthrough": { + "priority_override_score": 0 + } + }, + "audio": { + "aac": { + "priority_override_score": 0 + }, + "opus": { + "priority_override_score": 0 + }, + "speex": { + "priority_override_score": 0 + }, + "pcm": { + "priority_override_score": 0 + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index b1a321b..10b690c 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -3,6 +3,8 @@ package com.foxx.androidcast; import android.app.Application; import android.content.Context; +import com.foxx.androidcast.media.CodecPriorityCatalog; + /** Applies saved theme and locale at process start. */ public class AndroidCastApplication extends Application { @Override @@ -13,6 +15,7 @@ public class AndroidCastApplication extends Application { @Override public void onCreate() { super.onCreate(); + CodecPriorityCatalog.init(this); CastLocaleHelper.applyStoredLocale(this); CastThemeHelper.applyNightMode(this); CastTrayNotifier.syncOnAppStart(this); diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index f36283c..0ba057a 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -177,7 +177,7 @@ public final class CastSettingsBinder { private static void bindVideoCodec(AppCompatActivity activity, CastSettings settings, int spinnerId) { Spinner spinner = activity.findViewById(spinnerId); - List choices = CodecCatalog.videoCodecChoicesForUi(); + List choices = CodecCatalog.videoCodecChoicesForUi(settings); CastSettings.VideoCodec[] values = new CastSettings.VideoCodec[choices.size()]; String[] labels = new String[choices.size()]; boolean[] selectable = new boolean[choices.size()]; @@ -215,7 +215,7 @@ public final class CastSettingsBinder { if (spinner == null) { return; } - List choices = CodecCatalog.audioCodecChoicesForUi(); + List choices = CodecCatalog.audioCodecChoicesForUi(settings); CastSettings.AudioCodec[] values = new CastSettings.AudioCodec[choices.size()]; String[] labels = new String[choices.size()]; boolean[] selectable = new boolean[choices.size()]; diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java index 7b00d17..4d13905 100644 --- a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java @@ -3,8 +3,8 @@ package com.foxx.androidcast.media; import android.media.MediaFormat; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.sender.CodecCatalog; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -12,19 +12,19 @@ import java.util.Set; /** Picks a video MIME type both sides support, honoring user preference when possible. */ public final class CodecNegotiator { - private static final String[] PREFERENCE_ORDER = { - MediaFormat.MIMETYPE_VIDEO_HEVC, - MediaFormat.MIMETYPE_VIDEO_AVC, - MediaFormat.MIMETYPE_VIDEO_VP9, - MediaFormat.MIMETYPE_VIDEO_VP8, - }; - private CodecNegotiator() {} public static String negotiate(List senderEncoders, List receiverDecoders, CastSettings.VideoCodec preference) { + return negotiate(senderEncoders, receiverDecoders, preference, null); + } + + public static String negotiate(List senderEncoders, List receiverDecoders, + CastSettings.VideoCodec preference, CastSettings negotiationSettings) { Set enc = new HashSet<>(senderEncoders); Set dec = new HashSet<>(receiverDecoders); + CastSettings scoreSettings = negotiationSettings != null ? negotiationSettings : new CastSettings(); + if (preference == CastSettings.VideoCodec.H264) { return pick(MediaFormat.MIMETYPE_VIDEO_AVC, enc, dec); } @@ -37,10 +37,11 @@ public final class CodecNegotiator { if (preference == CastSettings.VideoCodec.LIBVPX_VP9) { return pick(MediaFormat.MIMETYPE_VIDEO_VP9, enc, dec); } - for (String mime : PREFERENCE_ORDER) { - if (enc.contains(mime) && dec.contains(mime)) { - return mime; - } + + String best = CodecPriorityCatalog.negotiateBestVideoMime( + senderEncoders, receiverDecoders, scoreSettings); + if (best != null) { + return best; } if (dec.contains(MediaFormat.MIMETYPE_VIDEO_AVC) && enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) { return MediaFormat.MIMETYPE_VIDEO_AVC; @@ -87,4 +88,9 @@ public final class CodecNegotiator { return MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime) || MediaFormat.MIMETYPE_VIDEO_VP9.equals(mime); } + + /** Score for diagnostics (lower = preferred). */ + public static int priorityScoreForNegotiatedMime(String mime, CastSettings settings) { + return CodecPriorityCatalog.combinedVideoMimeScore(mime, settings); + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecPriorityCatalog.java b/app/src/main/java/com/foxx/androidcast/media/CodecPriorityCatalog.java new file mode 100644 index 0000000..659e263 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/CodecPriorityCatalog.java @@ -0,0 +1,353 @@ +package com.foxx.androidcast.media; + +import android.content.Context; +import android.media.MediaFormat; +import android.util.Log; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; +import com.foxx.androidcast.sender.CodecCatalog; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** + * Runtime codec priority: tier base (0 / 500 / 1000) + component hooks. + * Lower {@link #priorityScore} = higher preference in AUTO negotiation and settings ordering. + */ +public final class CodecPriorityCatalog { + private static final String TAG = "CodecPriority"; + private static final String ASSET = "codecs.json"; + + private static final String KEY_INTERNAL = "internal_codec_score"; + private static final String KEY_FEC = "no_inband_fec_score"; + private static final String KEY_PROTECTION = "no_inband_protection_score"; + private static final String KEY_OVERRIDE = "priority_override_score"; + private static final String KEY_BITRATE = "bitrate_score"; + + private static volatile JSONObject rootJson = new JSONObject(); + private static volatile Context appContext; + + private CodecPriorityCatalog() {} + + public static void init(Context context) { + appContext = context.getApplicationContext(); + rootJson = loadJson(appContext); + } + + public static int priorityScore(CodecPriorityId id, CodecTier tier, CastSettings settings) { + if (id == null || tier == null) { + return Integer.MAX_VALUE; + } + return tier.baseScore + + component(id, KEY_INTERNAL, defaultInternal(id)) + + component(id, KEY_FEC, defaultFec(id)) + + component(id, KEY_PROTECTION, defaultProtection(id, settings)) + + component(id, KEY_OVERRIDE, 0) + + component(id, KEY_BITRATE, defaultBitrate(id)); + } + + public static int priorityScoreForVideoMime(String mime, CodecTier tier, CastSettings settings) { + CodecPriorityId id = CodecPriorityId.fromVideoMime(mime); + if (id == null) { + return CodecTier.PASSTHROUGH.baseScore + 100; + } + return priorityScore(id, tier, settings); + } + + public static int priorityScore(CastSettings.VideoCodec codec, CastSettings settings) { + CodecPriorityId id = CodecPriorityId.fromVideoPreference(codec); + if (id == null) { + return 0; + } + CodecTier tier = tierForVideoPreference(codec, id); + return priorityScore(id, tier, settings); + } + + public static int priorityScore(CastSettings.AudioCodec codec, CastSettings settings) { + CodecPriorityId id = CodecPriorityId.fromAudioPreference(codec); + if (id == null) { + return 0; + } + CodecTier tier = tierForAudioPreference(codec); + return priorityScore(id, tier, settings); + } + + /** Best MIME for AUTO: lowest score among codecs both sides can use. */ + public static String negotiateBestVideoMime(List senderEncoders, List receiverDecoders, + CastSettings settings) { + List common = commonMimes(senderEncoders, receiverDecoders); + if (common.isEmpty()) { + return null; + } + String best = null; + int bestScore = Integer.MAX_VALUE; + for (String mime : common) { + int score = Math.max( + videoMimeScoreOnDevice(mime, false, settings), + assumedRemoteEncoderScore(mime, settings)); + if (score < bestScore) { + bestScore = score; + best = mime; + } + } + return best; + } + + /** Conservative score when sender and receiver may use different tiers for the same MIME. */ + public static int combinedVideoMimeScore(String mime, CastSettings settings) { + int localEncoder = videoMimeScoreOnDevice(mime, true, settings); + int localDecoder = videoMimeScoreOnDevice(mime, false, settings); + int assumedPeerEncoder = assumedRemoteEncoderScore(mime, settings); + return Math.max(Math.max(localEncoder, localDecoder), assumedPeerEncoder); + } + + /** + * Estimates encoder-side score for the remote peer during handshake (only MIME caps known). + */ + public static int assumedRemoteEncoderScore(String mime, CastSettings remoteSettings) { + CodecPriorityId id = CodecPriorityId.fromVideoMime(mime); + if (id == null) { + return CodecTier.PASSTHROUGH.baseScore + 100; + } + CodecTier tier = assumedEncoderTier(id, remoteSettings); + return priorityScore(id, tier, remoteSettings); + } + + private static CodecTier assumedEncoderTier(CodecPriorityId id, CastSettings remote) { + CastSettings.VideoCodec pref = remote != null ? remote.getVideoCodec() : CastSettings.VideoCodec.AUTO; + if (pref == CastSettings.VideoCodec.PASSTHROUGH) { + return CodecTier.PASSTHROUGH; + } + if (pref == CastSettings.VideoCodec.LIBVPX_VP8 && id == CodecPriorityId.VP8) { + return CodecTier.SOFTWARE; + } + if (pref == CastSettings.VideoCodec.LIBVPX_VP9 && id == CodecPriorityId.VP9) { + return CodecTier.SOFTWARE; + } + if (id == CodecPriorityId.VP8 || id == CodecPriorityId.VP9) { + return CodecTier.HARDWARE; + } + return CodecTier.HARDWARE; + } + + public static int videoMimeScoreOnDevice(String mime, boolean encoder, CastSettings settings) { + CodecPriorityId id = CodecPriorityId.fromVideoMime(mime); + if (id == null) { + return CodecTier.PASSTHROUGH.baseScore + 100; + } + CodecTier tier = CodecCatalog.effectiveTierForVideoMime(mime, encoder); + return priorityScore(id, tier, settings); + } + + public static CodecTier tierForVideoPreference(CastSettings.VideoCodec preference, CodecPriorityId id) { + if (preference == CastSettings.VideoCodec.PASSTHROUGH) { + return CodecTier.PASSTHROUGH; + } + if (preference == CastSettings.VideoCodec.LIBVPX_VP8 + || preference == CastSettings.VideoCodec.LIBVPX_VP9) { + return CodecTier.SOFTWARE; + } + if (id == CodecPriorityId.VP8 || id == CodecPriorityId.VP9) { + return CodecTier.SOFTWARE; + } + return CodecTier.HARDWARE; + } + + public static CodecTier tierForAudioPreference(CastSettings.AudioCodec preference) { + if (preference == CastSettings.AudioCodec.SPEEX) { + return CodecTier.PASSTHROUGH; + } + if (preference == CastSettings.AudioCodec.OPUS) { + return CodecTier.SOFTWARE; + } + return CodecTier.HARDWARE; + } + + public static List sortByPriority(List> items) { + List> copy = new ArrayList<>(items); + Collections.sort(copy, Comparator.comparingInt(a -> a.priorityScore)); + List out = new ArrayList<>(copy.size()); + for (ScoredItem item : copy) { + out.add(item.value); + } + return out; + } + + public static final class ScoredItem { + public final T value; + public final int priorityScore; + + public ScoredItem(T value, int priorityScore) { + this.value = value; + this.priorityScore = priorityScore; + } + } + + private static List commonMimes(List enc, List dec) { + List out = new ArrayList<>(); + if (enc == null || dec == null) { + return out; + } + for (String mime : enc) { + if (mime != null && containsIgnoreCase(dec, mime)) { + out.add(mime); + } + } + return out; + } + + private static boolean containsIgnoreCase(List list, String mime) { + for (String s : list) { + if (s != null && s.equalsIgnoreCase(mime)) { + return true; + } + } + return false; + } + + private static int component(CodecPriorityId id, String key, int compileDefault) { + try { + JSONObject section = sectionFor(id); + if (section == null) { + return compileDefault; + } + JSONObject codec = section.optJSONObject(id.jsonKey); + if (codec != null && codec.has(key)) { + return codec.optInt(key, compileDefault); + } + } catch (Exception ignored) { + } + return compileDefault; + } + + private static JSONObject sectionFor(CodecPriorityId id) { + if (rootJson == null) { + return null; + } + switch (id) { + case H264: + case H265: + case VP8: + case VP9: + case PASSTHROUGH: + return rootJson.optJSONObject("video"); + case AAC: + case OPUS: + case SPEEX: + case PCM: + default: + return rootJson.optJSONObject("audio"); + } + } + + private static int defaultInternal(CodecPriorityId id) { + switch (id) { + case H264: + case VP9: + return 0; + case H265: + case OPUS: + return 1; + case VP8: + case AAC: + return 2; + case SPEEX: + return 5; + case PCM: + case PASSTHROUGH: + default: + return 10; + } + } + + private static int defaultFec(CodecPriorityId id) { + switch (id) { + case H264: + case OPUS: + return 0; + case H265: + case VP8: + case AAC: + return 1; + case VP9: + return 2; + case SPEEX: + return 5; + default: + return 1; + } + } + + private static int defaultProtection(CodecPriorityId id, CastSettings settings) { + switch (id) { + case VP8: + case VP9: + return vpxInbandProtectionScore(settings); + case H264: + case H265: + return 1; + case OPUS: + return 0; + case SPEEX: + return 5; + default: + return 1; + } + } + + /** 0 when libvpx in-band protection is available and enabled; 2 otherwise. */ + private static int vpxInbandProtectionScore(CastSettings settings) { + if (!NativeCodecBridge.isLibvpxAvailable()) { + return 2; + } + if (settings == null) { + return 0; + } + CastSettings.StreamProtection mode = settings.getStreamProtection(); + if (mode == null || mode == CastSettings.StreamProtection.AUTO + || mode == CastSettings.StreamProtection.NONE) { + return 2; + } + return 0; + } + + private static int defaultBitrate(CodecPriorityId id) { + switch (id) { + case H265: + case VP9: + case OPUS: + return 0; + case H264: + case AAC: + return 1; + case VP8: + return 2; + default: + return 10; + } + } + + private static JSONObject loadJson(Context context) { + try (BufferedReader r = new BufferedReader(new InputStreamReader( + context.getAssets().open(ASSET), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = r.readLine()) != null) { + sb.append(line); + } + return new JSONObject(sb.toString()); + } catch (Exception e) { + Log.w(TAG, "Using compile-time codec scores only (" + e.getMessage() + ")"); + return new JSONObject(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecPriorityId.java b/app/src/main/java/com/foxx/androidcast/media/CodecPriorityId.java new file mode 100644 index 0000000..2ee7009 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/CodecPriorityId.java @@ -0,0 +1,96 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +import com.foxx.androidcast.CastSettings; + +/** Canonical codec id for scoring tables and {@code codecs.json} keys. */ +public enum CodecPriorityId { + H264("h264"), + H265("h265"), + VP8("vp8"), + VP9("vp9"), + PASSTHROUGH("passthrough"), + AAC("aac"), + OPUS("opus"), + SPEEX("speex"), + PCM("pcm"); + + public final String jsonKey; + + CodecPriorityId(String jsonKey) { + this.jsonKey = jsonKey; + } + + public static CodecPriorityId fromVideoMime(String mime) { + if (mime == null) { + return null; + } + if (MediaFormat.MIMETYPE_VIDEO_AVC.equalsIgnoreCase(mime)) { + return H264; + } + if (MediaFormat.MIMETYPE_VIDEO_HEVC.equalsIgnoreCase(mime)) { + return H265; + } + if (MediaFormat.MIMETYPE_VIDEO_VP8.equalsIgnoreCase(mime)) { + return VP8; + } + if (MediaFormat.MIMETYPE_VIDEO_VP9.equalsIgnoreCase(mime)) { + return VP9; + } + return null; + } + + public static CodecPriorityId fromVideoPreference(CastSettings.VideoCodec codec) { + if (codec == null) { + return null; + } + switch (codec) { + case H264: + return H264; + case H265: + return H265; + case LIBVPX_VP8: + return VP8; + case LIBVPX_VP9: + return VP9; + case PASSTHROUGH: + return PASSTHROUGH; + case AUTO: + default: + return null; + } + } + + public static CodecPriorityId fromAudioPreference(CastSettings.AudioCodec codec) { + if (codec == null) { + return null; + } + switch (codec) { + case AAC: + return AAC; + case OPUS: + return OPUS; + case SPEEX: + return SPEEX; + case AUTO: + default: + return null; + } + } + + public String toMime() { + switch (this) { + case H264: + return MediaFormat.MIMETYPE_VIDEO_AVC; + case H265: + return MediaFormat.MIMETYPE_VIDEO_HEVC; + case VP8: + return MediaFormat.MIMETYPE_VIDEO_VP8; + case VP9: + return MediaFormat.MIMETYPE_VIDEO_VP9; + default: + return null; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecTier.java b/app/src/main/java/com/foxx/androidcast/media/CodecTier.java new file mode 100644 index 0000000..1359c84 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/CodecTier.java @@ -0,0 +1,14 @@ +package com.foxx.androidcast.media; + +/** Base tier added before per-codec component scores (lower total = higher priority). */ +public enum CodecTier { + HARDWARE(0), + SOFTWARE(500), + PASSTHROUGH(1000); + + public final int baseScore; + + CodecTier(int baseScore) { + this.baseScore = baseScore; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java index 2c0a92c..67a01b5 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -118,7 +118,7 @@ public class CastSession { List receiverCaps = CodecCatalog.localDecoderMimes(); transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps)); String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, - PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec())); + PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote); transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime)); remote.setNegotiatedVideoMime(mime); return new HandshakeResult(senderName, remote, mime); diff --git a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java index f4ff2aa..68d6c86 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CodecCatalog.java @@ -6,61 +6,87 @@ import android.media.MediaFormat; import android.os.Build; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.media.CodecPriorityCatalog; +import com.foxx.androidcast.media.CodecTier; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -/** Discovers device video encoder/decoder MIME types. */ +/** Discovers device video encoder/decoder MIME types and UI codec choices with priority scores. */ public final class CodecCatalog { + private static volatile CapabilitySnapshot capabilities; + private CodecCatalog() {} public static final class VideoCodecChoice { public final CastSettings.VideoCodec codec; public final boolean selectable; + /** Lower = higher priority in UI and AUTO negotiation hints. */ + public final int priorityScore; - public VideoCodecChoice(CastSettings.VideoCodec codec, boolean selectable) { + public VideoCodecChoice(CastSettings.VideoCodec codec, boolean selectable, int priorityScore) { this.codec = codec; this.selectable = selectable; + this.priorityScore = priorityScore; } } public static final class AudioCodecChoice { public final CastSettings.AudioCodec codec; public final boolean selectable; + public final int priorityScore; - public AudioCodecChoice(CastSettings.AudioCodec codec, boolean selectable) { + public AudioCodecChoice(CastSettings.AudioCodec codec, boolean selectable, int priorityScore) { this.codec = codec; this.selectable = selectable; + this.priorityScore = priorityScore; } } - /** Selectable + disabled future codecs for settings spinners. */ - public static List videoCodecChoicesForUi() { + /** Selectable codecs for settings spinners (AUTO first, then by ascending priority score). */ + public static List videoCodecChoicesForUi(CastSettings settings) { + ensureCapabilities(); List out = new ArrayList<>(); - out.add(new VideoCodecChoice(CastSettings.VideoCodec.AUTO, true)); - Set enc = new LinkedHashSet<>(localEncoderMimes()); + out.add(new VideoCodecChoice(CastSettings.VideoCodec.AUTO, true, 0)); + Set enc = capabilities.hardwareEncoderMimes; if (enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) { - out.add(new VideoCodecChoice(CastSettings.VideoCodec.H264, true)); + out.add(scoredVideo(CastSettings.VideoCodec.H264, true, settings)); } if (enc.contains(MediaFormat.MIMETYPE_VIDEO_HEVC)) { - out.add(new VideoCodecChoice(CastSettings.VideoCodec.H265, true)); + out.add(scoredVideo(CastSettings.VideoCodec.H265, true, settings)); } - out.add(new VideoCodecChoice(CastSettings.VideoCodec.LIBVPX_VP8, true)); - out.add(new VideoCodecChoice(CastSettings.VideoCodec.LIBVPX_VP9, true)); - out.add(new VideoCodecChoice(CastSettings.VideoCodec.PASSTHROUGH, true)); + if (capabilities.softwareEncoderMimes.contains(MediaFormat.MIMETYPE_VIDEO_VP8)) { + out.add(scoredVideo(CastSettings.VideoCodec.LIBVPX_VP8, true, settings)); + } + if (capabilities.softwareEncoderMimes.contains(MediaFormat.MIMETYPE_VIDEO_VP9)) { + out.add(scoredVideo(CastSettings.VideoCodec.LIBVPX_VP9, true, settings)); + } + out.add(scoredVideo(CastSettings.VideoCodec.PASSTHROUGH, true, settings)); + sortVideoChoices(out); + return out; + } + + public static List videoCodecChoicesForUi() { + return videoCodecChoicesForUi(null); + } + + public static List audioCodecChoicesForUi(CastSettings settings) { + List out = new ArrayList<>(); + out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0)); + out.add(scoredAudio(CastSettings.AudioCodec.AAC, true, settings)); + out.add(scoredAudio(CastSettings.AudioCodec.OPUS, true, settings)); + out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, true, settings)); + sortAudioChoices(out); return out; } public static List audioCodecChoicesForUi() { - List out = new ArrayList<>(); - out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true)); - out.add(new AudioCodecChoice(CastSettings.AudioCodec.AAC, true)); - out.add(new AudioCodecChoice(CastSettings.AudioCodec.SPEEX, true)); - out.add(new AudioCodecChoice(CastSettings.AudioCodec.OPUS, true)); - return out; + return audioCodecChoicesForUi(null); } public static List supportedChoices() { @@ -74,14 +100,115 @@ public final class CodecCatalog { } public static List localEncoderMimes() { - return listCodecMimes(true); + ensureCapabilities(); + return new ArrayList<>(capabilities.advertisedEncoderMimes); } public static List localDecoderMimes() { - return listCodecMimes(false); + ensureCapabilities(); + return new ArrayList<>(capabilities.advertisedDecoderMimes); } - private static List listCodecMimes(boolean encoder) { + public static boolean hasHardwareCodec(String mime, boolean encoder) { + ensureCapabilities(); + Set set = encoder ? capabilities.hardwareEncoderMimes : capabilities.hardwareDecoderMimes; + return containsIgnoreCase(set, mime); + } + + public static boolean hasSoftwareCodec(String mime, boolean encoder) { + ensureCapabilities(); + Set set = encoder ? capabilities.softwareEncoderMimes : capabilities.softwareDecoderMimes; + return containsIgnoreCase(set, mime); + } + + /** Tier used for scoring on this device for a MIME (HW preferred over SW when both exist). */ + public static CodecTier effectiveTierForVideoMime(String mime, boolean encoder) { + if (hasHardwareCodec(mime, encoder)) { + return CodecTier.HARDWARE; + } + if (hasSoftwareCodec(mime, encoder)) { + return CodecTier.SOFTWARE; + } + return CodecTier.PASSTHROUGH; + } + + public static boolean canUseVideoMimeOnDevice(String mime, boolean encoder) { + return hasHardwareCodec(mime, encoder) || hasSoftwareCodec(mime, encoder); + } + + private static VideoCodecChoice scoredVideo(CastSettings.VideoCodec codec, boolean selectable, + CastSettings settings) { + int score = CodecPriorityCatalog.priorityScore(codec, settings); + return new VideoCodecChoice(codec, selectable, score); + } + + private static AudioCodecChoice scoredAudio(CastSettings.AudioCodec codec, boolean selectable, + CastSettings settings) { + int score = CodecPriorityCatalog.priorityScore(codec, settings); + return new AudioCodecChoice(codec, selectable, score); + } + + private static void sortVideoChoices(List choices) { + if (choices.size() <= 2) { + return; + } + VideoCodecChoice auto = choices.get(0); + List rest = new ArrayList<>(choices.subList(1, choices.size())); + Collections.sort(rest, Comparator.comparingInt(c -> c.priorityScore)); + choices.clear(); + choices.add(auto); + choices.addAll(rest); + } + + private static void sortAudioChoices(List choices) { + if (choices.size() <= 2) { + return; + } + AudioCodecChoice auto = choices.get(0); + List rest = new ArrayList<>(choices.subList(1, choices.size())); + Collections.sort(rest, Comparator.comparingInt(c -> c.priorityScore)); + choices.clear(); + choices.add(auto); + choices.addAll(rest); + } + + private static void ensureCapabilities() { + if (capabilities != null) { + return; + } + synchronized (CodecCatalog.class) { + if (capabilities != null) { + return; + } + capabilities = probeCapabilities(); + } + } + + private static CapabilitySnapshot probeCapabilities() { + Set hwEnc = probeHardwareMimes(true); + Set hwDec = probeHardwareMimes(false); + Set swEnc = new LinkedHashSet<>(); + Set swDec = new LinkedHashSet<>(); + if (NativeCodecBridge.isLibvpxAvailable()) { + swEnc.add(MediaFormat.MIMETYPE_VIDEO_VP8); + swEnc.add(MediaFormat.MIMETYPE_VIDEO_VP9); + swDec.add(MediaFormat.MIMETYPE_VIDEO_VP8); + swDec.add(MediaFormat.MIMETYPE_VIDEO_VP9); + } + Set advertisedEnc = new LinkedHashSet<>(hwEnc); + advertisedEnc.addAll(swEnc); + if (advertisedEnc.isEmpty()) { + advertisedEnc.add(MediaFormat.MIMETYPE_VIDEO_AVC); + } + Set advertisedDec = new LinkedHashSet<>(hwDec); + advertisedDec.addAll(swDec); + if (advertisedDec.isEmpty()) { + advertisedDec.add(MediaFormat.MIMETYPE_VIDEO_AVC); + } + return new CapabilitySnapshot(hwEnc, hwDec, swEnc, swDec, advertisedEnc, advertisedDec); + } + + private static Set probeHardwareMimes(boolean encoder) { Set out = new LinkedHashSet<>(); String[] want = { MediaFormat.MIMETYPE_VIDEO_AVC, @@ -119,13 +246,38 @@ public final class CodecCatalog { } } } - if (out.isEmpty()) { - out.add(MediaFormat.MIMETYPE_VIDEO_AVC); + return out; + } + + private static boolean containsIgnoreCase(Set set, String mime) { + if (mime == null) { + return false; } - if (NativeCodecBridge.isLibvpxAvailable()) { - out.add(MediaFormat.MIMETYPE_VIDEO_VP8); - out.add(MediaFormat.MIMETYPE_VIDEO_VP9); + for (String s : set) { + if (s != null && s.equalsIgnoreCase(mime)) { + return true; + } + } + return false; + } + + private static final class CapabilitySnapshot { + final Set hardwareEncoderMimes; + final Set hardwareDecoderMimes; + final Set softwareEncoderMimes; + final Set softwareDecoderMimes; + final Set advertisedEncoderMimes; + final Set advertisedDecoderMimes; + + CapabilitySnapshot(Set hardwareEncoderMimes, Set hardwareDecoderMimes, + Set softwareEncoderMimes, Set softwareDecoderMimes, + Set advertisedEncoderMimes, Set advertisedDecoderMimes) { + this.hardwareEncoderMimes = hardwareEncoderMimes; + this.hardwareDecoderMimes = hardwareDecoderMimes; + this.softwareEncoderMimes = softwareEncoderMimes; + this.softwareDecoderMimes = softwareDecoderMimes; + this.advertisedEncoderMimes = advertisedEncoderMimes; + this.advertisedDecoderMimes = advertisedDecoderMimes; } - return new ArrayList<>(out); } }