0.2.42 - DTS surround sound support

This commit is contained in:
ponzischeme89
2026-08-10 12:23:23 +12:00
parent 32c0054c44
commit f41db66ac6
29 changed files with 1363 additions and 128 deletions
+16 -4
View File
@@ -136,10 +136,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
@@ -173,6 +169,16 @@ android {
}
}
// Outside `android { }` on purpose: the Kotlin compiler's own settings belong to the Kotlin
// plugin, not to AGP's extension, which is why `kotlinOptions` inside it is deprecated.
// Keep in step with compileOptions above — a jvmTarget below the Java target fails the
// build the moment Kotlin has to read a class Java compiled.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
@@ -227,6 +233,12 @@ dependencies {
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
// Moonfin's Android TV backend keeps a software audio renderer behind Media3 so a
// surround track that is not bitstreamed is decoded to PCM instead of making the
// server re-encode the video beside it. This build targets Media3 1.5.x; Jellyfin's
// 1.5.0 extension is the matching published binary for that line.
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
// Software-decoding fallback after Media3 exhausts its codec/container recovery.
// Kept out of the normal path: Android TV still gets hardware decode, passthrough,
// the full Memby OSD and the pre-roll from Media3 whenever the device can play it.
+13
View File
@@ -10,6 +10,12 @@
installer of record (Android 12+). Ignored before that, and never required: the
install session falls back to asking. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- Search's microphone button. The recogniser runs in its own activity and owns the
mic, so this is not always demanded — but several TV recognisers hand back an
empty result instead of listening when the caller holds no RECORD_AUDIO, which
reads as voice search being broken rather than as a permission being missing.
Asked for at the point of use; see QueryField in ui/search/SearchScreen.kt. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
@@ -48,6 +54,13 @@
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<!-- Load-bearing. RECORD_AUDIO implies a required android.hardware.microphone, and most
television boxes have no mic of their own (the remote does, and that is a separate
feature). Left implied, the permission above would make Memby uninstallable on a
large part of the fleet to add a button that hides itself anyway. -->
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<application
android:name=".MembyApp"
@@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
/**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -42,6 +43,9 @@ object ServiceLocator {
fun init(context: Context) {
if (::repository.isInitialized) return
// Only hands the probe an application context; it does no work until the first
// request or playback negotiation asks what this television's receiver accepts.
installAudioCapabilityProbe(context)
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, settings)
@@ -25,6 +25,7 @@ import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -2215,15 +2216,15 @@ class EmbyRepository(private val settings: SettingsStore) {
allowVideoStreamCopy = !forceTranscode,
subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId,
deviceProfile = if (forceTranscode) {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
)
.h264TranscodeFallback()
} else {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
)
// The viewer's own passthrough choice reaches the device profile as
// well as the audio sink: a set told to bitstream DTS must also ask
// Emby to send it, or the switch turns on a format nothing delivers.
deviceProfile = com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
audio = deviceAudioCapabilities,
passthrough = snapshot.audioPassthroughPreference,
).let { profile ->
if (forceTranscode) profile.h264TranscodeFallback() else profile
},
),
)
@@ -14,6 +14,10 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
@@ -326,6 +330,18 @@ data class Settings(
// Shrink the closing credits to one side at double speed with what is on next beside
// them. Per-profile and synced for the same reason the three above are.
val speedUpCredits: Boolean = true,
/**
* Whether surround formats are bitstreamed to the receiver by what the hardware probe
* reported, or by the viewer's own per-codec switches.
*
* Device-level, and deliberately not synced. What a soundbar accepts is a property of
* the room, not of the person sitting in it — pushing one lounge's DTS override onto
* every television in the house is exactly the failure the manual mode exists to fix.
* [audioPassthroughCodecs] is a comma-separated list of [SurroundCodec] slugs and is
* read only in manual mode.
*/
val audioPassthroughMode: String = DEFAULT_AUDIO_PASSTHROUGH_MODE,
val audioPassthroughCodecs: String = "",
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -408,6 +424,7 @@ data class Settings(
companion object {
const val DEFAULT_ROTATION_SECONDS = 15
const val DEFAULT_RING_COLOR = "FFFFFF"
val DEFAULT_AUDIO_PASSTHROUGH_MODE = AudioPassthroughMode.DEFAULT.value
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
const val DEFAULT_HOME_CARD_DENSITY = "standard"
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
@@ -418,6 +435,20 @@ data class Settings(
}
}
/**
* The two stored keys read back as the one decision the player and the device profile ask
* for. Defined on a nullable receiver because most callers hold [SettingsStore.current],
* which is null until DataStore has emitted — and "not read yet" must mean automatic
* rather than manual with nothing chosen, which is manual with everything switched off.
*/
val Settings?.audioPassthroughPreference: AudioPassthroughPreference
get() = this?.let {
AudioPassthroughPreference(
mode = AudioPassthroughMode.from(it.audioPassthroughMode),
codecs = SurroundCodec.setFrom(it.audioPassthroughCodecs),
)
} ?: AudioPassthroughPreference.AUTOMATIC
@Serializable
data class EmbyProfile(
val id: String,
@@ -499,6 +530,8 @@ class SettingsStore(private val context: Context) {
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits")
val AUDIO_PASSTHROUGH_MODE = stringPreferencesKey("audio_passthrough_mode")
val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -788,6 +821,24 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Records how surround audio should reach the receiver on *this* television.
*
* Both halves are written in one edit. They describe one decision, and DataStore
* rewrites the whole file per edit — a mode landing apart from the switches it selects
* would leave a set momentarily in manual mode with nothing chosen, which is silence
* on every surround track it opens in that window.
*/
suspend fun setAudioPassthrough(
mode: AudioPassthroughMode,
codecs: Set<SurroundCodec>,
) {
context.dataStore.edit { preferences ->
preferences[Keys.AUDIO_PASSTHROUGH_MODE] = mode.value
preferences[Keys.AUDIO_PASSTHROUGH_CODECS] = codecs.asSlugs()
}
}
/**
* Records the revision a successful push was stored under, without touching values.
* Separate from [applyRemotePreferences] because after a push this TV already holds
@@ -1363,6 +1414,9 @@ class SettingsStore(private val context: Context) {
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
audioPassthroughMode = AudioPassthroughMode
.from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value,
audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(),
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
@@ -1,8 +1,14 @@
package com.ponzischeme89.memby.data.model
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import com.ponzischeme89.memby.data.playback.channelLimitFor
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -80,8 +86,17 @@ data class DeviceProfile(
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
) {
companion object {
/**
* [audio] and [passthrough] are what stop an audio track being a reason to
* re-encode somebody's video. Every format this television can bitstream *or*
* decode is listed as direct-playable, so Emby only ever converts a track it has
* genuinely no other way to deliver — and when it does, the video beside it is
* copied rather than sent through an encoder.
*/
fun embyAndroidTv(
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
audio: DeviceAudioCapabilities = deviceAudioCapabilities,
passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
) = DeviceProfile(
name = "Memby Android TV",
subtitleProfiles = listOf(
@@ -94,7 +109,7 @@ data class DeviceProfile(
// The broad codec declaration is bounded by CodecProfiles below.
container = "mkv,mp4,m4v,mov,ts,mpegts",
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac,mp3",
audioCodec = audio.embyAudioCodecs(passthrough),
),
),
transcodingProfiles = listOf(
@@ -103,11 +118,13 @@ data class DeviceProfile(
// Permits Emby to remux a supported HEVC/H.264 video stream while
// converting only incompatible audio or subtitles.
videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac",
// A set that can bitstream Dolby asks for Dolby, so a 5.1 track that
// genuinely has to be converted does not arrive as stereo AAC.
audioCodec = audio.transcodeAudioCodecs(passthrough),
protocol = "hls",
),
),
codecProfiles = codecProfiles(capabilities),
codecProfiles = codecProfiles(capabilities) + audioCodecProfiles(audio, passthrough),
)
/** Compatibility for callers and older tests that only know the HEVC boolean. */
@@ -123,6 +140,33 @@ data class DeviceProfile(
private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
if (capabilities.hevc.supported) "h264,hevc" else "h264"
/**
* How many channels Emby may send. `VideoAudio` with no codec applies the limit to
* every audio track in a video file, which is what it is for — the constraint is
* the cable and the receiver, not any one format.
*
* A `MaxAudioChannels` of the full bitstream width is only claimed once something
* is actually being bitstreamed. With passthrough off, this television is the thing
* decoding, and promising eight channels it would then downmix itself only costs
* bandwidth on every stream.
*/
private fun audioCodecProfiles(
audio: DeviceAudioCapabilities,
passthrough: AudioPassthroughPreference,
): List<CodecProfile> = listOf(
CodecProfile(
type = "VideoAudio",
codec = "",
conditions = listOf(
ProfileCondition(
"LessThanEqual",
"AudioChannels",
audio.channelLimitFor(passthrough).toString(),
),
),
),
)
private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<CodecProfile> =
buildList {
addVideoProfiles("h264", capabilities.h264)
@@ -0,0 +1,142 @@
/*
* Bitstream audio passthrough adapted from Moonfin's TV backends.
*
* Moonfin: https://github.com/Moonfin-Client/Moonfin-Core
*
* Modifications Copyright (C) 2026 Memby contributors
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.data.playback
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Handler
import android.os.Looper
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioCapabilities
/**
* Asks the platform what the thing on the end of the HDMI cable accepts, and what this
* television can decode for itself if the answer is nothing.
*
* The probe needs a Context, which the video probe beside it does not, so it is installed
* from [com.ponzischeme89.memby.ServiceLocator] rather than being a plain `by lazy`. It is
* still evaluated on first *use* — the first gateway request or the first playback
* negotiation — and never during `Application.onCreate`.
*/
private object AudioProbeHolder {
@Volatile
var appContext: Context? = null
@Volatile
var cached: DeviceAudioCapabilities? = null
@Volatile
var watchingRoute: Boolean = false
}
/** Called once from the service locator, with the application context. */
fun installAudioCapabilityProbe(context: Context) {
AudioProbeHolder.appContext = context.applicationContext
}
/**
* What this set and its receiver turned out to support.
*
* A result is memoised only once it has actually been probed. Reading this before the
* context is installed answers "nothing known" *without* caching it, because the
* alternative — a `by lazy` that happened to be touched one call too early — would leave
* the television advertising a stereo-only profile for the rest of the process.
*/
val deviceAudioCapabilities: DeviceAudioCapabilities
get() = AudioProbeHolder.cached ?: probeAudioCapabilities()
@OptIn(UnstableApi::class)
private fun probeAudioCapabilities(): DeviceAudioCapabilities {
val context = AudioProbeHolder.appContext ?: return DeviceAudioCapabilities()
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
// behaviour this app had before passthrough existed.
.getOrElse { DeviceAudioCapabilities(probed = true) }
AudioProbeHolder.cached = probed
return probed
}
/**
* Moonfin re-probes when an AVR appears or disappears. Keep that behaviour here: Media3's
* automatic sink already follows the live route, and invalidating this snapshot makes the
* next gateway request and Settings visit describe the same route as the player.
*/
private fun watchAudioRoute(context: Context) {
if (AudioProbeHolder.watchingRoute) return
synchronized(AudioProbeHolder) {
if (AudioProbeHolder.watchingRoute) return
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
?: return
audioManager.registerAudioDeviceCallback(
object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
AudioProbeHolder.cached = null
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
AudioProbeHolder.cached = null
}
},
Handler(Looper.getMainLooper()),
)
AudioProbeHolder.watchingRoute = true
}
}
@UnstableApi
private class AndroidAudioCapabilityProbe(private val context: Context) {
fun probe(): DeviceAudioCapabilities {
// The same attributes the player is built with. Passthrough support is routing
// dependent, so probing under different attributes than playback uses would be
// asking a question about a different audio path.
val capabilities = AudioCapabilities.getCapabilities(
context,
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* routedDevice = */ null,
)
return DeviceAudioCapabilities(
passthrough = SurroundCodec.entries
.filterTo(mutableSetOf()) { capabilities.supportsEncoding(it.encoding()) },
// The platform probe remains useful for preferring its hardware decoder, but
// the packaged FFmpeg Media3 extension covers every format below when it does
// not. Advertise that real software fallback to Emby so audio alone never
// causes a video transcode.
decode = SurroundCodec.entries.toSet(),
maxChannelCount = capabilities.maxChannelCount,
probed = true,
)
}
}
/**
* The media3 encoding a bitstreamed track is written to the audio track as.
*
* Kept here rather than on [SurroundCodec] so the rule itself stays free of media3 types
* and testable without Android. This is also what the player's manual-mode override is
* 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)
internal fun SurroundCodec.encoding(): Int = when (this) {
SurroundCodec.AC3 -> C.ENCODING_AC3
SurroundCodec.EAC3 -> C.ENCODING_E_AC3
SurroundCodec.ATMOS -> C.ENCODING_E_AC3_JOC
SurroundCodec.DTS -> C.ENCODING_DTS
SurroundCodec.DTS_HD -> C.ENCODING_DTS_HD
SurroundCodec.TRUEHD -> C.ENCODING_DOLBY_TRUEHD
}
@@ -0,0 +1,262 @@
/*
* Bitstream audio passthrough adapted from Moonfin's TV backends, which carry the same
* feature on Android TV (Media3) and Apple TV (AetherEngine): a hardware probe that says
* what the receiver accepts, a manual per-codec override for the sets that misreport it,
* and a decode-to-PCM path underneath so a track always plays.
*
* Moonfin: https://github.com/Moonfin-Client/Moonfin-Core
*
* Modifications Copyright (C) 2026 Memby contributors
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.data.playback
/**
* The surround formats a receiver can be handed as a bitstream.
*
* Deliberately holds no media3 or Android type: this file is the rule, and the rule has to
* be readable by a plain JUnit test. The mapping to media3's own encoding constants lives
* next to the probe that needs them, in `AudioCapabilityProbe.kt`.
*
* [embyCodecs] is what Emby calls the format in a device profile, and is a *list* because
* ffmpeg's name and Emby's are not always the one word — DTS is `dts` or `dca` depending on
* which side of the library named the stream, and both appear in real libraries.
*
* [decodeMimeTypes] is a list for a different reason: an extension carried inside another
* format is decoded by that format's decoder. Atmos is E-AC-3 to a decoder that has never
* heard of it, and DTS-HD carries a plain DTS core, so either plays as PCM on a platform
* holding only the base decoder — which is the fallback this whole feature rests on.
*/
enum class SurroundCodec(
val slug: String,
val label: String,
val description: String,
val mimeType: String,
val embyCodecs: List<String>,
val decodeMimeTypes: List<String> = listOf(mimeType),
) {
AC3(
slug = "ac3",
label = "Dolby Digital",
description = "The 5.1 track on most films and broadcasts.",
mimeType = "audio/ac3",
embyCodecs = listOf("ac3"),
),
EAC3(
slug = "eac3",
label = "Dolby Digital Plus",
description = "The newer Dolby stream, common on streaming rips.",
mimeType = "audio/eac3",
embyCodecs = listOf("eac3"),
),
ATMOS(
slug = "atmos",
label = "Dolby Atmos",
description = "Height channels carried inside a Dolby Digital Plus stream.",
mimeType = "audio/eac3-joc",
// Atmos is not a container of its own: it rides inside E-AC-3 (and TrueHD, which
// has its own row). Advertising eac3 is what lets the stream through at all.
embyCodecs = listOf("eac3"),
decodeMimeTypes = listOf("audio/eac3-joc", "audio/eac3"),
),
DTS(
slug = "dts",
label = "DTS",
description = "The 5.1 track on most Blu-ray rips.",
mimeType = "audio/vnd.dts",
embyCodecs = listOf("dts", "dca"),
),
DTS_HD(
slug = "dts_hd",
label = "DTS-HD",
description = "DTS-HD Master Audio and High Resolution.",
mimeType = "audio/vnd.dts.hd",
embyCodecs = listOf("dts", "dca", "dtshd"),
decodeMimeTypes = listOf("audio/vnd.dts.hd", "audio/vnd.dts"),
),
TRUEHD(
slug = "truehd",
label = "Dolby TrueHD",
description = "Lossless Dolby, including TrueHD Atmos.",
mimeType = "audio/true-hd",
embyCodecs = listOf("truehd", "mlp"),
),
;
companion object {
fun from(slug: String): SurroundCodec? =
entries.firstOrNull { it.slug.equals(slug.trim(), ignoreCase = true) }
fun setFrom(slugs: String): Set<SurroundCodec> = slugs.split(',')
.mapNotNull(::from)
.toSet()
fun Set<SurroundCodec>.asSlugs(): String =
entries.filter { it in this }.joinToString(",") { it.slug }
}
}
/**
* What this television and whatever is plugged into it turned out to be able to do.
*
* [passthrough] is the hardware's own answer — media3's probe of the HDMI sink — and
* [decode] is what the platform holds a decoder for, which is the half that matters even
* when there is no receiver at all: a format Memby can decode is one Emby never has to
* transcode the *video* alongside.
*
* [probed] separates "asked, and the answer was nothing" from "never asked". A set that has
* not been probed must not have its silence read as a receiver that accepts nothing.
*/
data class DeviceAudioCapabilities(
val passthrough: Set<SurroundCodec> = emptySet(),
val decode: Set<SurroundCodec> = emptySet(),
val maxChannelCount: Int = STEREO_CHANNELS,
val probed: Boolean = false,
) {
companion object {
const val STEREO_CHANNELS = 2
/**
* What a bitstreamed track is worth asking Emby for once *any* passthrough is on.
* The receiver, not this television, is what will decode it, so the set's own
* output channel count says nothing about how many channels may be sent.
*/
const val BITSTREAM_CHANNELS = 8
/**
* Formats media3 decodes on every build, with no probe and no receiver involved.
* These are advertised unconditionally, which is the whole reason a FLAC or Opus
* track in an MKV no longer drags the video through an encoder with it.
*/
val ALWAYS_DECODABLE = listOf(
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
)
}
}
/**
* How the viewer wants the probe treated.
*
* [MANUAL] is deliberately *authoritative* rather than a narrowing of the probe. The whole
* reason it exists is a set whose platform lies about what its receiver accepts — a mode
* that could only ever subtract from the probe would be no use to the case it was added
* for. That cuts both ways, which is why the settings page states what was detected beside
* the switches rather than only offering them.
*/
enum class AudioPassthroughMode(val value: String) {
AUTO("auto"),
MANUAL("manual"),
;
companion object {
val DEFAULT = AUTO
fun from(value: String?): AudioPassthroughMode =
entries.firstOrNull { it.value.equals(value?.trim(), ignoreCase = true) } ?: DEFAULT
}
}
/** The viewer's answer to the probe: which mode, and what they chose if they chose. */
data class AudioPassthroughPreference(
val mode: AudioPassthroughMode = AudioPassthroughMode.DEFAULT,
val codecs: Set<SurroundCodec> = emptySet(),
) {
companion object {
val AUTOMATIC = AudioPassthroughPreference()
}
}
/**
* The formats that will actually be bitstreamed. Pure, and the one place the two modes are
* resolved — the player, the device profile and the gateway tokens all read this rather
* than each deciding what "manual" means.
*/
fun DeviceAudioCapabilities.passthroughFor(
preference: AudioPassthroughPreference,
): Set<SurroundCodec> = when (preference.mode) {
AudioPassthroughMode.AUTO -> passthrough
AudioPassthroughMode.MANUAL -> preference.codecs
}.withCarrierCodecs()
/**
* Extension formats cannot be handed to an audio sink without their carrier. This is the
* same dependency Moonfin applies to DTS-HD and its DTS core; Atmos similarly rides an
* E-AC-3 stream. Keeping it here means the player, Emby profile and gateway all agree.
*/
private fun Set<SurroundCodec>.withCarrierCodecs(): Set<SurroundCodec> = buildSet {
addAll(this@withCarrierCodecs)
if (SurroundCodec.DTS_HD in this@withCarrierCodecs) add(SurroundCodec.DTS)
if (SurroundCodec.ATMOS in this@withCarrierCodecs) add(SurroundCodec.EAC3)
}
/**
* Every surround format this television can put through the speakers one way or another.
*
* The union is the point of the feature. A track that is bitstreamed reaches the receiver
* untouched; one that is only decodable is turned into PCM here — and either way Emby is
* told it can send the file as it is, so the audio track alone stops being a reason to
* re-encode somebody's video.
*/
fun DeviceAudioCapabilities.playableFor(
preference: AudioPassthroughPreference,
): Set<SurroundCodec> = passthroughFor(preference) + decode
/**
* The `MaxAudioChannels` Emby is told to respect.
*
* With any passthrough on, the answer belongs to the receiver rather than to this set, so
* it is the full bitstream width. With none, it is what the sink reported it can render,
* floored at stereo — a probe that returned nothing must never advertise zero channels,
* which Emby reads as "downmix everything to mono".
*/
fun DeviceAudioCapabilities.channelLimitFor(preference: AudioPassthroughPreference): Int =
if (passthroughFor(preference).isNotEmpty()) {
DeviceAudioCapabilities.BITSTREAM_CHANNELS
} else {
maxOf(maxChannelCount, DeviceAudioCapabilities.STEREO_CHANNELS)
}
/**
* The Emby audio codec list for a direct-play profile: the baseline formats plus whatever
* surround the resolved preference makes playable, deduplicated and in a stable order.
*/
fun DeviceAudioCapabilities.embyAudioCodecs(preference: AudioPassthroughPreference): String {
val surround = playableFor(preference).flatMap(SurroundCodec::embyCodecs)
return (DeviceAudioCapabilities.ALWAYS_DECODABLE + surround).distinct().joinToString(",")
}
/**
* What Emby should convert an *unplayable* audio track into.
*
* A 5.1 track that has to be converted must not arrive as stereo AAC when there is a
* receiver on the other end of the cable, so a set that can bitstream Dolby asks for Dolby
* and keeps its channels. AAC stays on the end as the format every build can decode.
*/
fun DeviceAudioCapabilities.transcodeAudioCodecs(
preference: AudioPassthroughPreference,
): String {
val playable = playableFor(preference)
val preferred = listOf(SurroundCodec.EAC3, SurroundCodec.AC3)
.filter { it in playable }
.flatMap(SurroundCodec::embyCodecs)
return (preferred + listOf("aac", "mp3")).distinct().joinToString(",")
}
/**
* The capability tokens the gateway builds its own device profile from.
*
* Passthrough and decode are reported separately because they are different claims, and
* the gateway needs both: the union decides what may be direct-played, and the passthrough
* half alone decides how many channels may be sent and what a conversion should target.
*/
fun DeviceAudioCapabilities.gatewayAudioTokens(
preference: AudioPassthroughPreference,
): List<String> = buildList {
val bitstreamed = passthroughFor(preference)
SurroundCodec.entries.forEach { codec ->
if (codec in bitstreamed) add("audio_${codec.slug}_passthrough")
if (codec in decode) add("audio_${codec.slug}_decode")
}
add("audio_max_channels_${channelLimitFor(preference)}")
}
@@ -2,7 +2,11 @@ package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.audioPassthroughPreference
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.gatewayAudioTokens
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
@@ -57,14 +61,33 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
.header(
"X-Memby-Capabilities",
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens())
.joinToString(","),
(
MEMBY_CAPABILITIES +
devicePlaybackCapabilities.gatewayCapabilityTokens() +
audioCapabilityTokens()
).joinToString(","),
)
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it")
}
return chain.proceed(builder.build())
}
/**
* What this set can put through its speakers, resolved against the viewer's own
* passthrough choice — so a manual override reaches the *server's* device profile too,
* not only the audio sink. Read on each request rather than captured, because the
* override can be changed in Settings without restarting the process.
*
* It is guarded because this interceptor also runs before the service locator exists
* in a screenshot or instrumentation context, where an exception here would fail the
* request rather than merely describe the television modestly.
*/
private fun audioCapabilityTokens(): List<String> = runCatching {
deviceAudioCapabilities.gatewayAudioTokens(
ServiceLocator.settings.current.audioPassthroughPreference,
)
}.getOrDefault(emptyList())
}
internal const val MEMBY_PROTOCOL_VERSION = 1
@@ -84,6 +107,10 @@ internal val MEMBY_CAPABILITIES = listOf(
// Declares that this build can offer to skip an episode's opening titles, so the admin
// console reports the feature honestly against an older app that would never ask.
"skip_intro_v1",
// Declares that this build reports what its receiver accepts and can be sent a stream
// whose audio it will bitstream. A gateway seeing this knows the audio tokens beside
// it are a complete answer rather than an older app that simply never described itself.
"audio_passthrough_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -11,10 +11,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
@@ -22,31 +22,58 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import kotlinx.coroutines.delay
/** How long the last frame is held after the clip ends, before the launcher is uncovered. */
internal const val LAUNCH_INTRO_HOLD_MS = 2_000L
/**
* Memby's own short clip, played behind the cold-start screen.
* The longest the launcher may ever be held back by the opening clip.
*
* The clip was already prepared during launch and already borrowed by the player for a
* fresh playback's pre-roll; the one place it was never seen was the screen every launch
* shows. This is that screen — the same 200 KB local resource, the same process-cached
* [PrerollPreloader] instance, no second decoder and no second copy of the file.
* The clip is four seconds and the hold is two, so this is that plus room for a slow
* decoder on a weak box. It is the outer guarantee: whatever happens to the player, the
* viewer reaches their rows.
*/
internal const val LAUNCH_INTRO_MAX_MS = 9_000L
/** How long a borrowed player has to put a frame on screen before it is given up on. */
private const val LAUNCH_INTRO_FIRST_FRAME_MS = 3_000L
/**
* Whether this process has already played the opening clip.
*
* Process-scoped rather than persisted: the intro belongs to opening the app, and a
* television that has been sitting on the launcher all evening has already had it. It is
* also what stops the clip gating any *later* appearance of the loading screen — a profile
* switch is not an app launch.
*/
internal object LaunchIntro {
var played: Boolean = false
}
/**
* Memby's own short clip, played over the cold-start screen while the app opens.
*
* The clip used to be decoration: it looped quietly behind the "opening" text and the
* launcher uncovered it whenever it happened to be ready, which on a warm start was a
* fraction of a second — so the one thing Memby owns was, in practice, never seen. It now
* **gates the launcher**: it plays once from the beginning, its last frame is held for
* [LAUNCH_INTRO_HOLD_MS], and only then is the home screen composed.
*
* Things worth preserving:
*
* - **It is decoration and never a gate.** Every failure path is silent and leaves the
* pulsing-logo screen exactly as it was: an unavailable player, a decoder error, a set
* that cannot render the clip at all. Nothing about opening the launcher waits on it, and
* [onVisible] is called only once a frame has actually been drawn — so the fade never
* - **It gates, but it can never trap.** Every failure — no player, a decoder error, a set
* that renders no frame within [LAUNCH_INTRO_FIRST_FRAME_MS] — reports itself finished
* immediately, and [LAUNCH_INTRO_MAX_MS] in `AppRoot` is the outer bound on top of that.
* A viewer must never be held on a black screen by a branding clip.
* - **[onVisible] is the first *rendered* frame**, not the play call, so the fade never
* uncovers a black rectangle.
* - **It is muted.** A branding sting is written to run its length; the cold-start screen is
* commonly gone in a few hundred milliseconds, and a sound cut off a third of the way
* through on every single app open is worse than no sound. The clip keeps its audio where
* it plays to the end, which is the pre-roll before a programme.
* - **It loops.** A cold start on a slow connection outlasts six seconds, and a clip that
* ended would leave its last frame frozen under the "opening" text — which reads as the
* television having hung at precisely the moment the viewer is watching for that.
* [PrerollPreloader.acquire] puts the repeat mode back, so the pre-roll before a
* programme still ends.
* - **It does not loop any more.** Looping existed so a slow cold start never froze on the
* last frame; the hold and the fade-out do that job now, and a clip that never ends
* cannot gate anything.
* - **It is muted.** The launcher's own clip plays on every single app open, and a set that
* chimes each time it is switched on wears out fast. The pre-roll before a programme
* keeps its audio — [PrerollPreloader.acquire] puts the volume back.
* - **The player is returned, not released.** It goes back to the process cache on dispose,
* which is what lets the very next playback still open on a prepared instance.
*/
@@ -55,44 +82,59 @@ import com.ponzischeme89.memby.ui.player.PrerollPreloader
internal fun LaunchPrerollVideo(
modifier: Modifier = Modifier,
onVisible: () -> Unit,
onFinished: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnVisible by rememberUpdatedState(onVisible)
val currentOnFinished by rememberUpdatedState(onFinished)
// Deliberately *not* borrowed during composition. On a cold start there is nothing
// cached yet, so acquiring here would construct an ExoPlayer and open the local
// resource on the main thread inside the first composition of the one screen whose
// whole job is to appear immediately. Waiting a frame costs the clip a few tens of
// milliseconds nobody can see behind the fade, and costs the launcher nothing.
// whole job is to appear immediately.
var player by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
player = runCatching { PrerollPreloader.acquire(context) }.getOrNull()
val acquired = runCatching { PrerollPreloader.acquire(context) }.getOrNull()
if (acquired == null) currentOnFinished() else player = acquired
}
// A null is an ordinary outcome — the caller simply keeps the screen it already has.
// A null is an ordinary outcome — the caller has already been told to carry on.
val active = player ?: return
var rendered by remember(active) { mutableStateOf(false) }
var completed by remember(active) { mutableStateOf(false) }
var failed by remember(active) { mutableStateOf(false) }
DisposableEffect(active, lifecycleOwner) {
val listener = object : Player.Listener {
override fun onRenderedFirstFrame() = currentOnVisible()
override fun onRenderedFirstFrame() {
rendered = true
currentOnVisible()
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) completed = true
}
override fun onPlayerError(error: PlaybackException) {
// Nothing to say and nowhere to say it. The screen underneath is complete.
// Nothing to say and nowhere to say it. The screen underneath is complete,
// and the launcher must not wait on a clip that has stopped.
active.playWhenReady = false
failed = true
}
}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> active.playWhenReady = false
Lifecycle.Event.ON_START -> active.playWhenReady = true
Lifecycle.Event.ON_START -> if (!completed) active.playWhenReady = true
else -> Unit
}
}
active.addListener(listener)
lifecycleOwner.lifecycle.addObserver(observer)
active.volume = 0f
active.repeatMode = Player.REPEAT_MODE_ONE
active.repeatMode = Player.REPEAT_MODE_OFF
active.seekTo(0L)
if (active.playbackState == Player.STATE_IDLE) active.prepare()
active.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
@@ -104,6 +146,25 @@ internal fun LaunchPrerollVideo(
}
}
// A set that cannot draw the clip at all is indistinguishable, from here, from one that
// is simply slow — so give it a bounded chance and then get out of the way.
LaunchedEffect(active) {
delay(LAUNCH_INTRO_FIRST_FRAME_MS)
if (!rendered) failed = true
}
LaunchedEffect(completed, failed) {
when {
failed -> currentOnFinished()
completed -> {
// The pause the whole feature is for: the mark is on screen, still, long
// enough to have been looked at rather than glimpsed.
delay(LAUNCH_INTRO_HOLD_MS)
currentOnFinished()
}
}
}
AndroidView(
modifier = modifier,
factory = { viewContext ->
@@ -253,6 +253,19 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
var initialUpdateCheckComplete by remember { mutableStateOf(false) }
// Held for the length of Memby's opening clip plus its pause, once per process. Not a
// rememberSaveable: an activity recreated behind the viewer (returning from the TV home
// screen, a configuration change) is not an app launch, and LaunchIntro carries that
// across it.
var introHolding by remember { mutableStateOf(!LaunchIntro.played) }
LaunchedEffect(Unit) {
if (!introHolding) return@LaunchedEffect
// The clip is decoration; the rows are the app. Whatever the player is doing, this
// is the longest it may ever stand in front of them.
kotlinx.coroutines.delay(LAUNCH_INTRO_MAX_MS)
LaunchIntro.played = true
introHolding = false
}
var dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
var confirmingExit by rememberSaveable { mutableStateOf(false) }
// SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when
@@ -397,6 +410,12 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// it a moment later, twice, during the one stretch of a launch that is busiest.
// Hoisted to one call site, the screen and its clip survive the whole cold start.
val openingQuoteStyle = when {
// Memby's own clip runs to its end before anything else is drawn. It is the one
// thing the app owns and it was, in practice, never seen: the launcher uncovered
// it whenever it happened to be ready, which on a warm start was a fraction of a
// second. LAUNCH_INTRO_MAX_MS below is the outer bound — the rows are never more
// than that away, whatever the player does.
introHolding -> loaded?.welcomeQuoteStyle.orEmpty()
!initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
appUpdate != null -> null
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
@@ -408,7 +427,15 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
loaded.welcomeQuoteStyle.orEmpty()
else -> null
}
if (openingQuoteStyle != null) MembyLoadingScreen(quoteStyle = openingQuoteStyle)
if (openingQuoteStyle != null) {
MembyLoadingScreen(
quoteStyle = openingQuoteStyle,
onIntroFinished = {
LaunchIntro.played = true
introHolding = false
},
)
}
when {
openingQuoteStyle != null -> Unit
appUpdate != null -> UpdateScreen(
@@ -568,17 +595,38 @@ private fun ExitMembyConfirmation(
@androidx.media3.common.util.UnstableApi
@Composable
private fun MembyLoadingScreen(quoteStyle: String? = null) {
private fun MembyLoadingScreen(
quoteStyle: String? = null,
onIntroFinished: () -> Unit = {},
) {
val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) }
// Whether Memby's own clip is actually on screen behind this. Set from the player's
// first rendered frame rather than from having asked it to play, so the logo only gets
// out of the way once there is something to get out of the way for.
var prerollVisible by remember { mutableStateOf(false) }
// The clip has played its length and been held. Everything below it comes back.
var introDone by remember { mutableStateOf(false) }
// ...and a moment later the video node goes, which is what hands the player back to the
// process cache. Removing it on the same frame as the fade would cut to black.
var introRemoved by remember { mutableStateOf(false) }
val prerollAlpha by animateFloatAsState(
targetValue = if (prerollVisible) 1f else 0f,
targetValue = if (prerollVisible && !introDone) 1f else 0f,
animationSpec = tween(420),
label = "cold-start-preroll-alpha",
)
// While the clip runs it is the whole screen: the pulsing mark and the welcome line are
// the *waiting* screen, and printing them over a four-second sting says two things at
// once. They fade in behind it if the app is still opening when it ends.
val chromeAlpha by animateFloatAsState(
targetValue = if (prerollVisible && !introDone) 0f else 1f,
animationSpec = tween(420),
label = "cold-start-chrome-alpha",
)
LaunchedEffect(introDone) {
if (!introDone) return@LaunchedEffect
kotlinx.coroutines.delay(460L)
introRemoved = true
}
// Not keyed on the quote style: the headline says what the app is doing, and rerolling
// it when the settings flow arrives with a tone would change the line under the viewer
// mid-launch. Once per appearance of this screen is the intent.
@@ -614,34 +662,30 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
),
contentAlignment = Alignment.Center,
) {
LaunchPrerollVideo(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = prerollAlpha },
onVisible = { prerollVisible = true },
)
// Holds the copy legible over whatever frame the clip happens to be on. It fades in
// with the video rather than sitting there over the plain gradient, where it would
// only be darkening a screen that is already dark.
Box(
Modifier
.fillMaxSize()
.graphicsLayer { alpha = prerollAlpha }
.background(
Brush.verticalGradient(
colors = listOf(
MembySurface.copy(alpha = 0.35f),
MembySurface.copy(alpha = 0.82f),
),
),
),
)
if (!introRemoved) {
LaunchPrerollVideo(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = prerollAlpha },
onVisible = { prerollVisible = true },
onFinished = {
if (!introDone) {
introDone = true
onIntroFinished()
}
},
)
}
// The scrim that used to hold this copy legible over the clip is gone with the
// overlap it existed for: the waiting screen and the clip no longer share the frame,
// so darkening Memby's own mark by four fifths would be for nobody's benefit.
Column(
modifier = Modifier.graphicsLayer { alpha = chromeAlpha },
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// The clip is Memby's own mark moving; the still logo above it would be the
// same thing said twice, so it gives way as the video arrives.
// same thing said twice, so the whole column gives way while it runs.
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
@@ -651,7 +695,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.graphicsLayer {
scaleX = pulse
scaleY = pulse
alpha = (0.82f + (glow * 0.18f)) * (1f - prerollAlpha)
alpha = 0.82f + (glow * 0.18f)
},
)
Text(
@@ -0,0 +1,90 @@
/*
* Bitstream audio passthrough adapted from Moonfin's TV backends.
*
* Moonfin: https://github.com/Moonfin-Client/Moonfin-Core
*
* Modifications Copyright (C) 2026 Memby contributors
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.encoding
import com.ponzischeme89.memby.data.playback.passthroughFor
/**
* The renderers factory that decides whether a surround track reaches the receiver as a
* bitstream or is decoded here and sent as PCM.
*
* There is exactly one difference between the two modes and it is deliberate:
*
* - **Auto** overrides nothing. [DefaultAudioSink] built with a Context keeps its own
* capabilities receiver registered, so a soundbar switched on half an hour into an
* evening is noticed. A fixed snapshot taken at player construction could not be.
* - **Manual** supplies a fixed [AudioCapabilities] built from the viewer's switches. It
* is an override rather than a filter, because the case it exists for is a television
* whose platform reports the wrong answer — one that could only ever subtract would be
* no help to it.
*
* Whatever is not bitstreamed falls through to the platform decoder and comes out as PCM,
* so the worst a wrong switch can do is silence one format until it is switched back, and
* every track still plays with all of them off.
*/
@UnstableApi
internal class MembyRenderersFactory(
context: Context,
private val preference: AudioPassthroughPreference,
private val capabilities: DeviceAudioCapabilities = deviceAudioCapabilities,
) : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
): AudioSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.apply {
manualAudioCapabilities(preference, capabilities)?.let(::setAudioCapabilities)
}
.build()
}
/**
* The fixed capabilities a manual choice becomes, or null in automatic mode — where the
* sink is left to track the hardware itself.
*
* PCM is always in the list. It is not a surround format anybody chose; it is the output
* every decoded track is written as, and a capabilities object omitting it describes a
* television that cannot play audio at all.
*/
@OptIn(UnstableApi::class)
internal fun manualAudioCapabilities(
preference: AudioPassthroughPreference,
capabilities: DeviceAudioCapabilities,
): AudioCapabilities? {
if (preference.mode != AudioPassthroughMode.MANUAL) return null
val bitstreamed = capabilities.passthroughFor(preference)
val encodings = buildList {
add(C.ENCODING_PCM_16BIT)
SurroundCodec.entries.filter { it in bitstreamed }.forEach { add(it.encoding()) }
}
val channels = if (bitstreamed.isEmpty()) {
maxOf(capabilities.maxChannelCount, DeviceAudioCapabilities.STEREO_CHANNELS)
} else {
DeviceAudioCapabilities.BITSTREAM_CHANNELS
}
return AudioCapabilities(encodings.toIntArray(), channels)
}
@@ -59,6 +59,7 @@ import coil.load
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.audioPassthroughPreference
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing
@@ -491,7 +492,9 @@ class PlayerActivity : ComponentActivity() {
// queue behind can be seen until the first frame lands anyway. Nothing here can
// race the listener either — media3 posts its callbacks to this thread, so the
// first one cannot arrive until onCreate has returned.
player = PlayerEngine.create(this)
// Surround passthrough is settled before the sink is built, not after: an audio
// sink cannot change its mind about bitstreaming a format once a track is open.
player = PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference)
.also { playback ->
view.player = playback
trace.mark(PlaybackTrace.PLAYER_BUILT)
@@ -12,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
@@ -32,14 +33,26 @@ import java.util.concurrent.TimeUnit
@UnstableApi
internal object PlayerEngine {
fun create(context: Context): ExoPlayer = ExoPlayer.Builder(context)
/**
* [audio] decides whether a surround track is bitstreamed to the receiver or decoded
* here into PCM. It defaults to automatic, which is also the right answer for the
* pre-roll: that clip is stereo AAC, and it borrows this same builder.
*/
fun create(
context: Context,
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory(
DefaultRenderersFactory(context)
MembyRenderersFactory(context, audio)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true),
.setEnableDecoderFallback(true)
// 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),
)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
@@ -2,10 +2,13 @@
package com.ponzischeme89.memby.ui.search
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -379,6 +382,31 @@ private fun QueryField(
if (!spoken.isNullOrEmpty()) onVoiceResult(spoken)
}
val startVoiceSearch = {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
}
// A device can advertise recognition and still have nothing to launch; failing
// silently beats crashing the search screen.
runCatching { voiceLauncher.launch(intent) }
Unit
}
// The recogniser is asked for either way. Its own activity holds the mic, so a viewer
// who declines here is simply back to the behaviour this had before the prompt existed
// — which works on plenty of devices — and refusing to open it would take away a
// capability they may still have. What the grant buys is the devices that quietly hand
// back an empty result instead, where the failure looks like Memby's rather than like a
// permission nobody was ever asked for. A "don't ask again" denial returns immediately,
// so this never becomes a dialog between the viewer and the microphone button.
val micPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { startVoiceSearch() }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -416,16 +444,18 @@ private fun QueryField(
FocusScaleContainer(
onFocused = {},
onClick = {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
// Asked at the point of use rather than during setup: the microphone is
// only ever wanted by this one button, and a permission dialog in front
// of a new television is one nobody can connect to anything.
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
startVoiceSearch()
} else {
runCatching { micPermission.launch(Manifest.permission.RECORD_AUDIO) }
}
// A device can advertise recognition and still have nothing to
// launch; failing silently beats crashing the search screen.
runCatching { voiceLauncher.launch(intent) }
},
contentDescription = "Search by voice",
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
@@ -89,6 +89,10 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.ImageCacheMaintenance
import com.ponzischeme89.memby.data.audioPassthroughPreference
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
@@ -123,8 +127,8 @@ internal data class ChoiceOption(val value: String, val label: String, val color
private val RingOptions = listOf(
ChoiceOption("FFFFFF", "White", Color.White),
ChoiceOption("52B54B", "Memby green", Color(0xFF52B54B)),
ChoiceOption("E50914", "Cinema red", Color(0xFFE50914)),
ChoiceOption("52B54B", "Emby Green", Color(0xFF52B54B)),
ChoiceOption("E50914", "Netflix Red", Color(0xFFE50914)),
)
private val DensityOptions = listOf(
@@ -156,6 +160,21 @@ private val WelcomeOptions = WelcomeQuoteStyle.entries.map {
ChoiceOption(it.value, it.label)
}
private val AudioPassthroughOptions = listOf(
ChoiceOption(AudioPassthroughMode.AUTO.value, "Auto"),
ChoiceOption(AudioPassthroughMode.MANUAL.value, "Manual"),
)
/**
* Whether Settings Appearance offers the colour-scheme picker.
*
* Off: choosing a scheme does not reliably repaint the app yet, and a control that appears
* to do nothing is read as a fault in the television. Seasonal themes, the synced `themeId`
* preference and the palette plumbing are all unaffected this hides the question, it does
* not remove the answer.
*/
private const val THEME_PICKER_ENABLED = false
internal enum class SettingsPage(
val label: String,
val description: String,
@@ -208,6 +227,9 @@ internal data class SettingsPanelState(
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val speedUpCredits: Boolean = true,
val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO,
val audioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
val detectedAudioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
@@ -265,6 +287,8 @@ internal data class SettingsPanelActions(
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {},
val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> },
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
@@ -315,6 +339,12 @@ fun SettingsSheet(
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) }
var audioPassthroughMode by remember {
mutableStateOf(settings.audioPassthroughPreference.mode)
}
var audioPassthroughCodecs by remember {
mutableStateOf(settings.audioPassthroughPreference.codecs)
}
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
@@ -392,6 +422,8 @@ fun SettingsSheet(
settings.seekIntervalSeconds,
settings.skipIntroMode,
settings.speedUpCredits,
settings.audioPassthroughMode,
settings.audioPassthroughCodecs,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
@@ -400,6 +432,8 @@ fun SettingsSheet(
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
speedUpCredits = settings.speedUpCredits
audioPassthroughMode = settings.audioPassthroughPreference.mode
audioPassthroughCodecs = settings.audioPassthroughPreference.codecs
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
@@ -426,6 +460,9 @@ fun SettingsSheet(
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
speedUpCredits = speedUpCredits,
audioPassthroughMode = audioPassthroughMode,
audioPassthroughCodecs = audioPassthroughCodecs,
detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
@@ -482,6 +519,26 @@ fun SettingsSheet(
speedUpCredits = it
scope.launch { store.setSpeedUpCredits(it) }
},
onAudioPassthroughModeChanged = { mode ->
audioPassthroughMode = mode
// Moonfin seeds a first manual visit from the live probe. That makes Manual
// start as an editable copy of Auto instead of unexpectedly switching every
// surround format off the moment the viewer opens it.
if (mode == AudioPassthroughMode.MANUAL && audioPassthroughCodecs.isEmpty()) {
audioPassthroughCodecs = deviceAudioCapabilities.passthrough
}
scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) }
},
onAudioPassthroughCodecChanged = { codec, enabled ->
audioPassthroughCodecs = if (enabled) {
audioPassthroughCodecs + codec
} else {
audioPassthroughCodecs - codec
}
scope.launch {
store.setAudioPassthrough(audioPassthroughMode, audioPassthroughCodecs)
}
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -728,7 +785,13 @@ internal fun SettingsPanelContent(
// the direct path, on an older gateway, and for a viewer the operator
// has left with one scheme, there is no question to ask — and a row of
// one chip that cannot be moved is worse than no row.
if (state.themeOptions.size > 1) {
//
// Withheld outright while THEME_PICKER_ENABLED is false: a choice that
// does not visibly take effect is worse than no choice, because the
// viewer's conclusion is that the television is broken rather than that
// the feature is unfinished. Everything behind it — the preference, the
// sync, the palette — is untouched, so this is one flag to put back.
if (THEME_PICKER_ENABLED && state.themeOptions.size > 1) {
SettingsColourSchemeRow(
options = state.themeOptions,
selected = state.themeId,
@@ -774,6 +837,38 @@ internal fun SettingsPanelContent(
)
}
SettingsPage.PLAYBACK -> SettingsGroup {
SettingsChoiceRow(
title = "Surround sound passthrough",
description = "Auto follows the television and receiver. Manual lets you correct a device that reports the wrong formats.",
options = AudioPassthroughOptions,
selected = state.audioPassthroughMode.value,
onSelected = { value ->
actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value))
},
)
if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) {
SurroundCodec.entries.forEach { codec ->
SettingDivider()
SettingsToggleRow(
title = codec.label,
description = buildString {
append(codec.description)
append(
if (codec in state.detectedAudioPassthroughCodecs) {
" Detected on this audio output."
} else {
" Not detected; enable only if your receiver accepts it."
},
)
},
checked = codec in state.audioPassthroughCodecs,
onCheckedChange = { enabled ->
actions.onAudioPassthroughCodecChanged(codec, enabled)
},
)
}
}
SettingDivider()
SettingsToggleRow(
title = "Ten minutes left",
description = "A small reminder near the end of what you're watching.",
@@ -956,7 +1051,7 @@ internal fun SettingsPanelContent(
Text(
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary,
fontSize = 13.sp,
fontSize = 12.sp,
)
}
Text(
@@ -0,0 +1,69 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.channelLimitFor
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
import com.ponzischeme89.memby.data.playback.gatewayAudioTokens
import com.ponzischeme89.memby.data.playback.passthroughFor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DeviceAudioCapabilitiesTest {
private val capabilities = DeviceAudioCapabilities(
passthrough = setOf(SurroundCodec.AC3, SurroundCodec.EAC3),
decode = SurroundCodec.entries.toSet(),
maxChannelCount = 6,
probed = true,
)
@Test
fun `automatic follows detected receiver formats`() {
assertEquals(
setOf(SurroundCodec.AC3, SurroundCodec.EAC3),
capabilities.passthroughFor(AudioPassthroughPreference.AUTOMATIC),
)
}
@Test
fun `manual is authoritative and adds extension carriers`() {
val manual = AudioPassthroughPreference(
AudioPassthroughMode.MANUAL,
setOf(SurroundCodec.DTS_HD, SurroundCodec.ATMOS),
)
assertEquals(
setOf(SurroundCodec.DTS_HD, SurroundCodec.DTS, SurroundCodec.ATMOS, SurroundCodec.EAC3),
capabilities.passthroughFor(manual),
)
}
@Test
fun `software decodable codecs stay direct playable with passthrough off`() {
val off = AudioPassthroughPreference(AudioPassthroughMode.MANUAL, emptySet())
val codecs = capabilities.embyAudioCodecs(off)
SurroundCodec.entries.flatMap { it.embyCodecs }.forEach { codec ->
assertTrue("missing $codec from $codecs", codec in codecs.split(','))
}
assertEquals(6, capabilities.channelLimitFor(off))
}
@Test
fun `gateway separates decode and passthrough evidence`() {
val tokens = capabilities.gatewayAudioTokens(AudioPassthroughPreference.AUTOMATIC)
assertTrue("audio_ac3_passthrough" in tokens)
assertTrue("audio_truehd_decode" in tokens)
assertTrue("audio_max_channels_8" in tokens)
}
@Test
fun `device profile copies video when only audio conversion is needed`() {
val profile = DeviceProfile.embyAndroidTv(audio = capabilities)
assertTrue(profile.directPlayProfiles.single().audioCodec.contains("truehd"))
assertEquals("eac3,ac3,aac,mp3", profile.transcodingProfiles.single().audioCodec)
assertEquals("h264", profile.transcodingProfiles.single().videoCodec)
}
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile
import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import org.junit.Assert.assertEquals
@@ -33,12 +34,19 @@ class PlaybackReportMathTest {
@Test
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single()
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single()
val baseline = DeviceProfile.embyAndroidTv(
audio = DeviceAudioCapabilities(),
).directPlayProfiles.single()
val capable = DeviceProfile.embyAndroidTv(
capabilities = DevicePlaybackCapabilities(
hevc = VideoDecoderCapabilities(supported = true),
),
audio = DeviceAudioCapabilities(),
).directPlayProfiles.single()
assertEquals("h264", baseline.videoCodec)
assertEquals("h264,hevc", capable.videoCodec)
assertEquals("aac,mp3", capable.audioCodec)
assertEquals("aac,mp3,flac,opus,vorbis,pcm_s16le,pcm_s24le", capable.audioCodec)
assertFalse(capable.videoCodec.contains("av1"))
assertFalse(capable.audioCodec.contains("eac3"))
}