Release v0.2.43
This commit is contained in:
@@ -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
|
## 0.2.42 — 2026-08-10
|
||||||
- Added: Memby's own opening clip now plays while the app is starting up.
|
- 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.
|
- Added: Surround sound passthrough for Dolby Digital, Dolby Digital Plus, Dolby Atmos, DTS, DTS-HD and TrueHD, with automatic detection and manual receiver overrides.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.42"
|
val defaultVersionName = "0.2.43"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -56,10 +56,13 @@ fun installAudioCapabilityProbe(context: Context) {
|
|||||||
val deviceAudioCapabilities: DeviceAudioCapabilities
|
val deviceAudioCapabilities: DeviceAudioCapabilities
|
||||||
get() = AudioProbeHolder.cached ?: probeAudioCapabilities()
|
get() = AudioProbeHolder.cached ?: probeAudioCapabilities()
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@UnstableApi
|
||||||
private fun probeAudioCapabilities(): DeviceAudioCapabilities {
|
private fun probeAudioCapabilities(): DeviceAudioCapabilities {
|
||||||
val context = AudioProbeHolder.appContext ?: return 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() }
|
val probed = runCatching { AndroidAudioCapabilityProbe(context).probe() }
|
||||||
// A vendor build with broken audio metadata must still play something: an empty
|
// 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
|
// 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
|
* built from, so the set the probe is asked about and the set the sink is told about can
|
||||||
* never fall out of step.
|
* never fall out of step.
|
||||||
*/
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@UnstableApi
|
||||||
internal fun SurroundCodec.encoding(): Int = when (this) {
|
internal fun SurroundCodec.encoding(): Int = when (this) {
|
||||||
SurroundCodec.AC3 -> C.ENCODING_AC3
|
SurroundCodec.AC3 -> C.ENCODING_AC3
|
||||||
SurroundCodec.EAC3 -> C.ENCODING_E_AC3
|
SurroundCodec.EAC3 -> C.ENCODING_E_AC3
|
||||||
|
|||||||
@@ -53,15 +53,33 @@ internal class MembyRenderersFactory(
|
|||||||
context: Context,
|
context: Context,
|
||||||
enableFloatOutput: Boolean,
|
enableFloatOutput: Boolean,
|
||||||
enableAudioTrackPlaybackParams: Boolean,
|
enableAudioTrackPlaybackParams: Boolean,
|
||||||
): AudioSink = DefaultAudioSink.Builder(context)
|
): AudioSink {
|
||||||
.setEnableFloatOutput(enableFloatOutput)
|
val manualCapabilities = manualAudioCapabilities(preference, capabilities)
|
||||||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
val builder = if (manualCapabilities == null) {
|
||||||
.apply {
|
DefaultAudioSink.Builder(context)
|
||||||
manualAudioCapabilities(preference, capabilities)?.let(::setAudioCapabilities)
|
} 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
|
* The fixed capabilities a manual choice becomes, or null in automatic mode — where the
|
||||||
* sink is left to track the hardware itself.
|
* 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
|
* every decoded track is written as, and a capabilities object omitting it describes a
|
||||||
* television that cannot play audio at all.
|
* television that cannot play audio at all.
|
||||||
*/
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@UnstableApi
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
internal fun manualAudioCapabilities(
|
internal fun manualAudioCapabilities(
|
||||||
preference: AudioPassthroughPreference,
|
preference: AudioPassthroughPreference,
|
||||||
capabilities: DeviceAudioCapabilities,
|
capabilities: DeviceAudioCapabilities,
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ internal object PlayerEngine {
|
|||||||
fun create(
|
fun create(
|
||||||
context: Context,
|
context: Context,
|
||||||
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
|
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)
|
): ExoPlayer = ExoPlayer.Builder(context)
|
||||||
.setMediaSourceFactory(mediaSourceFactory(context))
|
.setMediaSourceFactory(mediaSourceFactory(context))
|
||||||
.setRenderersFactory(
|
.setRenderersFactory(
|
||||||
@@ -52,7 +67,7 @@ internal object PlayerEngine {
|
|||||||
// The bundled FFmpeg audio renderer sits after platform decoders. It is
|
// The bundled FFmpeg audio renderer sits after platform decoders. It is
|
||||||
// reached only when Android cannot decode a surround format itself; its
|
// reached only when Android cannot decode a surround format itself; its
|
||||||
// output is PCM, so passthrough-capable tracks still bypass it untouched.
|
// output is PCM, so passthrough-capable tracks still bypass it untouched.
|
||||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
.setExtensionRendererMode(extensionRendererMode),
|
||||||
)
|
)
|
||||||
.setTrackSelector(DefaultTrackSelector(context))
|
.setTrackSelector(DefaultTrackSelector(context))
|
||||||
.setLoadControl(loadControl())
|
.setLoadControl(loadControl())
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ internal object PrerollPreloader {
|
|||||||
if (context != null) {
|
if (context != null) {
|
||||||
val cached = cachedPlayer
|
val cached = cachedPlayer
|
||||||
if (cached == null) {
|
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) {
|
} else if (cached.playbackState == ExoPlayer.STATE_IDLE) {
|
||||||
cached.prepare()
|
runCatching { cached.prepare() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
|||||||
Reference in New Issue
Block a user