diff --git a/CHANGELOG.md b/CHANGELOG.md index f181294..f92ced0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.43 — 2026-08-10 +- Fixed: App no longer crashes. +- Fixed: Manual surround-sound choices now reliably override the automatically detected audio output. + ## 0.2.42 — 2026-08-10 - Added: Memby's own opening clip now plays while the app is starting up. - Added: Surround sound passthrough for Dolby Digital, Dolby Digital Plus, Dolby Atmos, DTS, DTS-HD and TrueHD, with automatic detection and manual receiver overrides. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c99ea0d..7f18ad6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.42" +val defaultVersionName = "0.2.43" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt b/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt index 2c2bf36..2a9b670 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt @@ -56,10 +56,13 @@ fun installAudioCapabilityProbe(context: Context) { val deviceAudioCapabilities: DeviceAudioCapabilities get() = AudioProbeHolder.cached ?: probeAudioCapabilities() -@OptIn(UnstableApi::class) +@UnstableApi private fun probeAudioCapabilities(): DeviceAudioCapabilities { val context = AudioProbeHolder.appContext ?: return DeviceAudioCapabilities() - watchAudioRoute(context) + // Route callbacks are useful but optional. A handful of vendor AudioManager + // implementations throw while registering one; that must not escape the otherwise + // defensive probe and take the launcher down with it. + runCatching { watchAudioRoute(context) } val probed = runCatching { AndroidAudioCapabilityProbe(context).probe() } // A vendor build with broken audio metadata must still play something: an empty // probe means PCM through whatever the platform decoders can manage, which is the @@ -131,7 +134,7 @@ private class AndroidAudioCapabilityProbe(private val context: Context) { * built from, so the set the probe is asked about and the set the sink is told about can * never fall out of step. */ -@OptIn(UnstableApi::class) +@UnstableApi internal fun SurroundCodec.encoding(): Int = when (this) { SurroundCodec.AC3 -> C.ENCODING_AC3 SurroundCodec.EAC3 -> C.ENCODING_E_AC3 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt index 6e423c8..f676b8e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt @@ -53,15 +53,33 @@ internal class MembyRenderersFactory( context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, - ): AudioSink = DefaultAudioSink.Builder(context) - .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) - .apply { - manualAudioCapabilities(preference, capabilities)?.let(::setAudioCapabilities) + ): AudioSink { + val manualCapabilities = manualAudioCapabilities(preference, capabilities) + val builder = if (manualCapabilities == null) { + DefaultAudioSink.Builder(context) + } else { + fixedCapabilitiesAudioSinkBuilder(manualCapabilities) } - .build() + return builder + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .build() + } } +/** + * Builds the fixed-capabilities sink used by manual mode. + * + * Media3 1.5 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has + * a Context, because the live route receiver replaces the supplied value. Its deprecated + * context-free builder is therefore the only 1.5 API that can honour a viewer override. + */ +@Suppress("DEPRECATION") +private fun fixedCapabilitiesAudioSinkBuilder( + capabilities: AudioCapabilities, +): DefaultAudioSink.Builder = DefaultAudioSink.Builder() + .setAudioCapabilities(capabilities) + /** * The fixed capabilities a manual choice becomes, or null in automatic mode — where the * sink is left to track the hardware itself. @@ -70,7 +88,8 @@ internal class MembyRenderersFactory( * every decoded track is written as, and a capabilities object omitting it describes a * television that cannot play audio at all. */ -@OptIn(UnstableApi::class) +@UnstableApi +@Suppress("DEPRECATION") internal fun manualAudioCapabilities( preference: AudioPassthroughPreference, capabilities: DeviceAudioCapabilities, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt index 794d3b2..962248e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt @@ -41,6 +41,21 @@ internal object PlayerEngine { fun create( context: Context, audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC, + ): ExoPlayer = try { + buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + } catch (_: RuntimeException) { + // Extension construction happens while ExoPlayer is built. A binary mismatch or + // broken vendor audio implementation must degrade to Android's renderers rather + // than make Memby close during its process-wide pre-roll warm-up. + buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) + } catch (_: LinkageError) { + buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) + } + + private fun buildPlayer( + context: Context, + audio: AudioPassthroughPreference, + extensionRendererMode: Int, ): ExoPlayer = ExoPlayer.Builder(context) .setMediaSourceFactory(mediaSourceFactory(context)) .setRenderersFactory( @@ -52,7 +67,7 @@ internal object PlayerEngine { // The bundled FFmpeg audio renderer sits after platform decoders. It is // reached only when Android cannot decode a surround format itself; its // output is PCM, so passthrough-capable tracks still bypass it untouched. - .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON), + .setExtensionRendererMode(extensionRendererMode), ) .setTrackSelector(DefaultTrackSelector(context)) .setLoadControl(loadControl()) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt index 7d333f9..d3c7a93 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -40,9 +40,13 @@ internal object PrerollPreloader { if (context != null) { val cached = cachedPlayer if (cached == null) { - cachedPlayer = createPreparedPlayer(context) + // This runs directly from the main looper rather than from a + // coroutine, so an exception here is otherwise an uncaught process + // crash. The opening clip is optional: a broken vendor audio stack or + // unavailable extension must skip it and leave the launcher running. + cachedPlayer = runCatching { createPreparedPlayer(context) }.getOrNull() } else if (cached.playbackState == ExoPlayer.STATE_IDLE) { - cached.prepare() + runCatching { cached.prepare() } } } false