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
+2
View File
@@ -1,5 +1,7 @@
## 0.2.41 — 2026-08-10 ## 0.2.41 — 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.
- Improved: Unsupported surround tracks are decoded to PCM on the TV, and audio conversion no longer makes Emby re-encode otherwise compatible video.
- Fixed: Colour schemes now repaint the whole app. The home screen, Settings, Search and the opening screen only followed a theme in part. - Fixed: Colour schemes now repaint the whole app. The home screen, Settings, Search and the opening screen only followed a theme in part.
- Improved: A far wider range of welcome lines and opening messages, so the same one comes round much less often. - Improved: A far wider range of welcome lines and opening messages, so the same one comes round much less often.
- Improved: The opening screen is no longer rebuilt as Memby moves between start-up steps. - Improved: The opening screen is no longer rebuilt as Memby moves between start-up steps.
+28 -8
View File
@@ -1196,14 +1196,26 @@ in the next idle window rather than constructing one per title.
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
there. It borrows the *same* cached instance — no second decoder, no second copy of the there. It borrows the *same* cached instance — no second decoder, no second copy of the
file — and hands it back on dispose, so the next playback still opens on a prepared player. file — and hands it back on dispose, so the next playback still opens on a prepared player.
Things to preserve: it is **decoration and never a gate**, so every failure is silent and **It now gates the launcher**, which is the whole point of it: it was decoration, uncovered
leaves the pulsing-logo screen exactly as it was, and the logo only fades out on whenever the app happened to be ready, and on a warm start that was a fraction of a second —
`onRenderedFirstFrame` rather than on having asked the player to start; it is acquired so the one thing Memby owns was in practice never seen. The clip plays once from the
**after** `withFrameNanos`, because a cold start has nothing cached and constructing an beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home
ExoPlayer inside the first composition of the screen that must appear immediately is the screen composed. Things to preserve: **it gates but it can never trap** — no player, a
cost the idle-handler prepare exists to avoid; it is muted and looped, where the pre-roll decoder error, or no rendered frame within three seconds all report finished immediately,
before a programme is audible and must *end* — `PrerollPreloader.acquire`/`recycle` and `LAUNCH_INTRO_MAX_MS` (9s, the clip's four plus the hold plus room for a weak box) is
normalise volume and repeat mode so a borrower cannot leave the next one wedged. And `AppRoot`'s outer bound on top of that, because a viewer must never be held on a black
screen by a branding clip; **`LaunchIntro.played` is process-scoped, not saved**, so an
activity Android recreates behind somebody is not a second launch and a profile switch does
not replay it; the pulsing mark and the welcome line fade *out* under the clip and back in
if the app is still opening when it ends, since the waiting screen and a four-second sting
said two things at once; and it no longer loops — looping existed so a slow cold start never
froze on the last frame, which the hold and the fade-out do instead, and a clip that never
ends cannot gate anything. It is still muted, because it now runs on every single app open,
where the pre-roll before a programme is audible and must *end* — `PrerollPreloader.acquire`
/`recycle` normalise volume and repeat mode so a borrower cannot leave the next one wedged.
It is acquired **after** `withFrameNanos`, because a cold start has nothing cached and
constructing an ExoPlayer inside the first composition of the screen that must appear
immediately is the cost the idle-handler prepare exists to avoid. And
`AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still `AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still
opening" were three, and Compose identifies a composable by where it is called from, so opening" were three, and Compose identifies a composable by where it is called from, so
moving between them disposed the screen and rebuilt it — which now means returning and moving between them disposed the screen and rebuilt it — which now means returning and
@@ -1616,6 +1628,14 @@ that never names a colour. Shape and punctuation are deliberately *not* themeabl
palette that could move a corner radius could make a layout wrong from the server, and the palette that could move a corner radius could make a layout wrong from the server, and the
whole safety of this feature is that the worst a bad theme does is look bad. whole safety of this feature is that the worst a bad theme does is look bad.
**The colour-scheme picker is currently withheld from Settings** —
`THEME_PICKER_ENABLED` in `ui/settings/SettingsSheet.kt`, one `const val` to put back.
Choosing a scheme does not reliably repaint the app, and a control that appears to do
nothing is read as a fault in the television rather than as an unfinished feature.
Everything below is otherwise untouched: seasonal themes still arrive and still apply, the
synced `themeId` preference is still carried, and the palette plumbing is unchanged. This
hides the question, not the answer.
**Themes** are `server/internal/api/themes.go`, and there are two kinds. A **selectable** **Themes** are `server/internal/api/themes.go`, and there are two kinds. A **selectable**
theme is the viewer's own choice, held as the ordinary synced preference `themeId` and theme is the viewer's own choice, held as the ordinary synced preference `themeId` and
picked in Settings → Appearance. A **seasonal** theme (Halloween, Christmas, Easter) is not picked in Settings → Appearance. A **seasonal** theme (Halloween, Christmas, Easter) is not
+17
View File
@@ -28,6 +28,19 @@ The adapted Memby files identify both upstream projects, identify Memby's 2026
modifications, and remain under GPLv2. Copyright in the upstream work remains modifications, and remain under GPLv2. Copyright in the upstream work remains
with its respective contributors. with its respective contributors.
Moonfin
-------
Memby's surround-audio capability probe, Auto/manual per-codec passthrough
rules, Media3 audio-sink configuration, and software-decode fallback are adapted
from Moonfin's Android TV playback backend, licensed under GNU GPL version 2:
https://github.com/Moonfin-Client/Moonfin-Core
The adapted files identify Moonfin, identify Memby's 2026 modifications, and
remain under GPLv2. Copyright in the upstream work remains with the Moonfin
contributors.
Third-party software Third-party software
-------------------- --------------------
@@ -38,6 +51,10 @@ source distributions and packaged dependency metadata. Those components
remain governed by their own licences; GPLv2 applies to Memby's original remain governed by their own licences; GPLv2 applies to Memby's original
and combined application code as required by the licence. and combined application code as required by the licence.
The Media3 software audio fallback uses Jellyfin's FFmpeg decoder extension:
https://github.com/jellyfin/jellyfin-androidx-media
The compatibility playback path uses mpv-android-lib, an Android libmpv The compatibility playback path uses mpv-android-lib, an Android libmpv
wrapper distributed under the MIT License: wrapper distributed under the MIT License:
+9 -4
View File
@@ -251,9 +251,12 @@ different app, so on every TV:
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from - Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC
profiles, maximum levels and resolutions rather than assuming every decoder handles every profiles, maximum levels and resolutions rather than assuming every decoder handles every
file. Emby can preserve a supported video stream while converting only incompatible audio file. The Moonfin-derived audio path reports formats the TV can decode or bitstream, offers
or subtitles. If a vendor decoder still fails, Media3 tries another decoder and Memby automatic receiver detection and manual per-codec passthrough overrides, and decodes the
ultimately requests an H.264 HLS transcode instead of abandoning playback. rest to PCM through Media3's FFmpeg renderer. Emby can preserve a supported video stream
while converting only incompatible audio or subtitles. If a vendor decoder still fails,
Media3 tries another decoder and Memby ultimately requests an H.264 HLS transcode instead
of abandoning playback.
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only - Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only
server this is unnecessary but harmless. server this is unnecessary but harmless.
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared. - The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
@@ -268,5 +271,7 @@ under **Settings → About / Licences**.
Playback capability probing and device-profile generation contain GPLv2 adaptations from Playback capability probing and device-profile generation contain GPLv2 adaptations from
[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from [Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from
[Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv). Attribution and [Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv). Surround-audio
capability detection, passthrough controls and software fallback are adapted from
[Moonfin](https://github.com/Moonfin-Client/Moonfin-Core) under GPLv2. Attribution and
modification notices are preserved in the adapted source files. modification notices are preserved in the adapted source files.
+16 -4
View File
@@ -136,10 +136,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = 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 { dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01") val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom) implementation(composeBom)
@@ -227,6 +233,12 @@ dependencies {
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt. // media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1") 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. // Software-decoding fallback after Media3 exhausts its codec/container recovery.
// Kept out of the normal path: Android TV still gets hardware decode, passthrough, // 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. // 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 installer of record (Android 12+). Ignored before that, and never required: the
install session falls back to asking. --> install session falls back to asking. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <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 <!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. --> "Play in Emby" / "Screensaver settings" silently do nothing. -->
@@ -48,6 +54,13 @@
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="true" /> 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 <application
android:name=".MembyApp" android:name=".MembyApp"
@@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
/** /**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the * Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -42,6 +43,9 @@ object ServiceLocator {
fun init(context: Context) { fun init(context: Context) {
if (::repository.isInitialized) return 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) settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings) repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, 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.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.TrickplayClient import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@@ -2215,15 +2216,15 @@ class EmbyRepository(private val settings: SettingsStore) {
allowVideoStreamCopy = !forceTranscode, allowVideoStreamCopy = !forceTranscode,
subtitleStreamIndex = subtitleStreamIndex, subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId, currentPlaySessionId = currentPlaySessionId,
deviceProfile = if (forceTranscode) { // The viewer's own passthrough choice reaches the device profile as
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv( // well as the audio sink: a set told to bitstream DTS must also ask
capabilities = devicePlaybackCapabilities, // Emby to send it, or the switch turns on a format nothing delivers.
) deviceProfile = com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
.h264TranscodeFallback() capabilities = devicePlaybackCapabilities,
} else { audio = deviceAudioCapabilities,
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv( passthrough = snapshot.audioPassthroughPreference,
capabilities = devicePlaybackCapabilities, ).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.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore 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.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch 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 // 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. // them. Per-profile and synced for the same reason the three above are.
val speedUpCredits: Boolean = true, 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. // Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR, val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null, val lastBackdropUrl: String? = null,
@@ -408,6 +424,7 @@ data class Settings(
companion object { companion object {
const val DEFAULT_ROTATION_SECONDS = 15 const val DEFAULT_ROTATION_SECONDS = 15
const val DEFAULT_RING_COLOR = "FFFFFF" 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_SECTIONS = "continue,favorites,latest"
const val DEFAULT_HOME_CARD_DENSITY = "standard" const val DEFAULT_HOME_CARD_DENSITY = "standard"
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic" 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 @Serializable
data class EmbyProfile( data class EmbyProfile(
val id: String, val id: String,
@@ -499,6 +530,8 @@ class SettingsStore(private val context: Context) {
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds") val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode") val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits") 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 RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections") 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. * Records the revision a successful push was stored under, without touching values.
* Separate from [applyRemotePreferences] because after a push this TV already holds * 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]), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, 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, ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL], lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
@@ -1,8 +1,14 @@
package com.ponzischeme89.memby.data.model 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.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities 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.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -80,8 +86,17 @@ data class DeviceProfile(
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(), @SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
) { ) {
companion object { 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( fun embyAndroidTv(
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities, capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
audio: DeviceAudioCapabilities = deviceAudioCapabilities,
passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
) = DeviceProfile( ) = DeviceProfile(
name = "Memby Android TV", name = "Memby Android TV",
subtitleProfiles = listOf( subtitleProfiles = listOf(
@@ -94,7 +109,7 @@ data class DeviceProfile(
// The broad codec declaration is bounded by CodecProfiles below. // The broad codec declaration is bounded by CodecProfiles below.
container = "mkv,mp4,m4v,mov,ts,mpegts", container = "mkv,mp4,m4v,mov,ts,mpegts",
videoCodec = directPlayVideoCodecs(capabilities), videoCodec = directPlayVideoCodecs(capabilities),
audioCodec = "aac,mp3", audioCodec = audio.embyAudioCodecs(passthrough),
), ),
), ),
transcodingProfiles = listOf( transcodingProfiles = listOf(
@@ -103,11 +118,13 @@ data class DeviceProfile(
// Permits Emby to remux a supported HEVC/H.264 video stream while // Permits Emby to remux a supported HEVC/H.264 video stream while
// converting only incompatible audio or subtitles. // converting only incompatible audio or subtitles.
videoCodec = directPlayVideoCodecs(capabilities), 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", protocol = "hls",
), ),
), ),
codecProfiles = codecProfiles(capabilities), codecProfiles = codecProfiles(capabilities) + audioCodecProfiles(audio, passthrough),
) )
/** Compatibility for callers and older tests that only know the HEVC boolean. */ /** 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 = private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
if (capabilities.hevc.supported) "h264,hevc" else "h264" 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> = private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<CodecProfile> =
buildList { buildList {
addVideoProfiles("h264", capabilities.h264) 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.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.ponzischeme89.memby.BuildConfig 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.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.gatewayAudioTokens
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor 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-Protocol", MEMBY_PROTOCOL_VERSION.toString())
.header( .header(
"X-Memby-Capabilities", "X-Memby-Capabilities",
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens()) (
.joinToString(","), MEMBY_CAPABILITIES +
devicePlaybackCapabilities.gatewayCapabilityTokens() +
audioCapabilityTokens()
).joinToString(","),
) )
tokenProvider()?.takeIf { it.isNotBlank() }?.let { tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it") builder.header("Authorization", "Bearer $it")
} }
return chain.proceed(builder.build()) 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 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 // 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. // console reports the feature honestly against an older app that would never ask.
"skip_intro_v1", "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" 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.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@@ -22,31 +22,58 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.ponzischeme89.memby.ui.player.PrerollPreloader 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 * The clip is four seconds and the hold is two, so this is that plus room for a slow
* fresh playback's pre-roll; the one place it was never seen was the screen every launch * decoder on a weak box. It is the outer guarantee: whatever happens to the player, the
* shows. This is that screen the same 200 KB local resource, the same process-cached * viewer reaches their rows.
* [PrerollPreloader] instance, no second decoder and no second copy of the file. */
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: * Things worth preserving:
* *
* - **It is decoration and never a gate.** Every failure path is silent and leaves the * - **It gates, but it can never trap.** Every failure no player, a decoder error, a set
* pulsing-logo screen exactly as it was: an unavailable player, a decoder error, a set * that renders no frame within [LAUNCH_INTRO_FIRST_FRAME_MS] reports itself finished
* that cannot render the clip at all. Nothing about opening the launcher waits on it, and * immediately, and [LAUNCH_INTRO_MAX_MS] in `AppRoot` is the outer bound on top of that.
* [onVisible] is called only once a frame has actually been drawn so the fade never * 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. * uncovers a black rectangle.
* - **It is muted.** A branding sting is written to run its length; the cold-start screen is * - **It does not loop any more.** Looping existed so a slow cold start never froze on the
* commonly gone in a few hundred milliseconds, and a sound cut off a third of the way * last frame; the hold and the fade-out do that job now, and a clip that never ends
* through on every single app open is worse than no sound. The clip keeps its audio where * cannot gate anything.
* it plays to the end, which is the pre-roll before a programme. * - **It is muted.** The launcher's own clip plays on every single app open, and a set that
* - **It loops.** A cold start on a slow connection outlasts six seconds, and a clip that * chimes each time it is switched on wears out fast. The pre-roll before a programme
* ended would leave its last frame frozen under the "opening" text which reads as the * keeps its audio [PrerollPreloader.acquire] puts the volume back.
* 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.
* - **The player is returned, not released.** It goes back to the process cache on dispose, * - **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. * 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( internal fun LaunchPrerollVideo(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onVisible: () -> Unit, onVisible: () -> Unit,
onFinished: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnVisible by rememberUpdatedState(onVisible) val currentOnVisible by rememberUpdatedState(onVisible)
val currentOnFinished by rememberUpdatedState(onFinished)
// Deliberately *not* borrowed during composition. On a cold start there is nothing // 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 // 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 // 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 // whole job is to appear immediately.
// milliseconds nobody can see behind the fade, and costs the launcher nothing.
var player by remember { mutableStateOf<ExoPlayer?>(null) } var player by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withFrameNanos { } 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 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) { DisposableEffect(active, lifecycleOwner) {
val listener = object : Player.Listener { 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) { 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 active.playWhenReady = false
failed = true
} }
} }
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_STOP -> active.playWhenReady = false 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 else -> Unit
} }
} }
active.addListener(listener) active.addListener(listener)
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
active.volume = 0f active.volume = 0f
active.repeatMode = Player.REPEAT_MODE_ONE active.repeatMode = Player.REPEAT_MODE_OFF
active.seekTo(0L) active.seekTo(0L)
if (active.playbackState == Player.STATE_IDLE) active.prepare() if (active.playbackState == Player.STATE_IDLE) active.prepare()
active.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) 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( AndroidView(
modifier = modifier, modifier = modifier,
factory = { viewContext -> factory = { viewContext ->
@@ -253,6 +253,19 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) } var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
var initialUpdateCheckComplete by remember { mutableStateOf(false) } 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 dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
var confirmingExit by rememberSaveable { mutableStateOf(false) } var confirmingExit by rememberSaveable { mutableStateOf(false) }
// SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when // 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. // 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. // Hoisted to one call site, the screen and its clip survive the whole cold start.
val openingQuoteStyle = when { 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() !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
appUpdate != null -> null appUpdate != null -> null
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty() loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
@@ -408,7 +427,15 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
loaded.welcomeQuoteStyle.orEmpty() loaded.welcomeQuoteStyle.orEmpty()
else -> null else -> null
} }
if (openingQuoteStyle != null) MembyLoadingScreen(quoteStyle = openingQuoteStyle) if (openingQuoteStyle != null) {
MembyLoadingScreen(
quoteStyle = openingQuoteStyle,
onIntroFinished = {
LaunchIntro.played = true
introHolding = false
},
)
}
when { when {
openingQuoteStyle != null -> Unit openingQuoteStyle != null -> Unit
appUpdate != null -> UpdateScreen( appUpdate != null -> UpdateScreen(
@@ -568,17 +595,38 @@ private fun ExitMembyConfirmation(
@androidx.media3.common.util.UnstableApi @androidx.media3.common.util.UnstableApi
@Composable @Composable
private fun MembyLoadingScreen(quoteStyle: String? = null) { private fun MembyLoadingScreen(
quoteStyle: String? = null,
onIntroFinished: () -> Unit = {},
) {
val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) }
// Whether Memby's own clip is actually on screen behind this. Set from the player's // 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 // 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. // out of the way once there is something to get out of the way for.
var prerollVisible by remember { mutableStateOf(false) } 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( val prerollAlpha by animateFloatAsState(
targetValue = if (prerollVisible) 1f else 0f, targetValue = if (prerollVisible && !introDone) 1f else 0f,
animationSpec = tween(420), animationSpec = tween(420),
label = "cold-start-preroll-alpha", 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 // 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 // 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. // mid-launch. Once per appearance of this screen is the intent.
@@ -614,34 +662,30 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
LaunchPrerollVideo( if (!introRemoved) {
modifier = Modifier LaunchPrerollVideo(
.fillMaxSize() modifier = Modifier
.graphicsLayer { alpha = prerollAlpha }, .fillMaxSize()
onVisible = { prerollVisible = true }, .graphicsLayer { alpha = prerollAlpha },
) onVisible = { prerollVisible = true },
// Holds the copy legible over whatever frame the clip happens to be on. It fades in onFinished = {
// with the video rather than sitting there over the plain gradient, where it would if (!introDone) {
// only be darkening a screen that is already dark. introDone = true
Box( onIntroFinished()
Modifier }
.fillMaxSize() },
.graphicsLayer { alpha = prerollAlpha } )
.background( }
Brush.verticalGradient( // The scrim that used to hold this copy legible over the clip is gone with the
colors = listOf( // overlap it existed for: the waiting screen and the clip no longer share the frame,
MembySurface.copy(alpha = 0.35f), // so darkening Memby's own mark by four fifths would be for nobody's benefit.
MembySurface.copy(alpha = 0.82f),
),
),
),
)
Column( Column(
modifier = Modifier.graphicsLayer { alpha = chromeAlpha },
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// The clip is Memby's own mark moving; the still logo above it would be the // 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( Image(
painter = painterResource(R.drawable.emby_logo), painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby", contentDescription = "Memby",
@@ -651,7 +695,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.graphicsLayer { .graphicsLayer {
scaleX = pulse scaleX = pulse
scaleY = pulse scaleY = pulse
alpha = (0.82f + (glow * 0.18f)) * (1f - prerollAlpha) alpha = 0.82f + (glow * 0.18f)
}, },
) )
Text( 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 coil.request.ImageRequest
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator 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.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing 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 // 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 // race the listener either — media3 posts its callbacks to this thread, so the
// first one cannot arrive until onCreate has returned. // 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 -> .also { playback ->
view.player = playback view.player = playback
trace.mark(PlaybackTrace.PLAYER_BUILT) trace.mark(PlaybackTrace.PLAYER_BUILT)
@@ -12,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.remote.HttpStack import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -32,14 +33,26 @@ import java.util.concurrent.TimeUnit
@UnstableApi @UnstableApi
internal object PlayerEngine { 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)) .setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory( .setRenderersFactory(
DefaultRenderersFactory(context) MembyRenderersFactory(context, audio)
// Some Android TV firmwares advertise a preferred hardware decoder which // Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder // fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported. // 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)) .setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl()) .setLoadControl(loadControl())
@@ -2,10 +2,13 @@
package com.ponzischeme89.memby.ui.search package com.ponzischeme89.memby.ui.search
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -379,6 +382,31 @@ private fun QueryField(
if (!spoken.isNullOrEmpty()) onVoiceResult(spoken) 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -416,16 +444,18 @@ private fun QueryField(
FocusScaleContainer( FocusScaleContainer(
onFocused = {}, onFocused = {},
onClick = { onClick = {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { // Asked at the point of use rather than during setup: the microphone is
putExtra( // only ever wanted by this one button, and a permission dialog in front
RecognizerIntent.EXTRA_LANGUAGE_MODEL, // of a new television is one nobody can connect to anything.
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, val granted = ContextCompat.checkSelfPermission(
) context,
putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title") 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", contentDescription = "Search by voice",
modifier = Modifier.clip(RoundedCornerShape(8.dp)), 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_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.ImageCacheMaintenance 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.ImageCacheSize
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO 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( private val RingOptions = listOf(
ChoiceOption("FFFFFF", "White", Color.White), ChoiceOption("FFFFFF", "White", Color.White),
ChoiceOption("52B54B", "Memby green", Color(0xFF52B54B)), ChoiceOption("52B54B", "Emby Green", Color(0xFF52B54B)),
ChoiceOption("E50914", "Cinema red", Color(0xFFE50914)), ChoiceOption("E50914", "Netflix Red", Color(0xFFE50914)),
) )
private val DensityOptions = listOf( private val DensityOptions = listOf(
@@ -156,6 +160,21 @@ private val WelcomeOptions = WelcomeQuoteStyle.entries.map {
ChoiceOption(it.value, it.label) 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( internal enum class SettingsPage(
val label: String, val label: String,
val description: String, val description: String,
@@ -208,6 +227,9 @@ internal data class SettingsPanelState(
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val speedUpCredits: Boolean = true, val speedUpCredits: Boolean = true,
val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO,
val audioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
val detectedAudioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
val ringColor: String = "52B54B", val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"), val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard", val cardDensity: String = "standard",
@@ -265,6 +287,8 @@ internal data class SettingsPanelActions(
val onSeekIntervalChanged: (Int) -> Unit = {}, val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {},
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {}, val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {},
val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> },
val onRingColorChanged: (String) -> Unit = {}, val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> }, val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {}, val onCardDensityChanged: (String) -> Unit = {},
@@ -315,6 +339,12 @@ fun SettingsSheet(
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) } 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 ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) } var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
@@ -392,6 +422,8 @@ fun SettingsSheet(
settings.seekIntervalSeconds, settings.seekIntervalSeconds,
settings.skipIntroMode, settings.skipIntroMode,
settings.speedUpCredits, settings.speedUpCredits,
settings.audioPassthroughMode,
settings.audioPassthroughCodecs,
settings.welcomeQuoteStyle, settings.welcomeQuoteStyle,
) { ) {
showLogo = settings.showTitleLogo showLogo = settings.showTitleLogo
@@ -400,6 +432,8 @@ fun SettingsSheet(
seekInterval = settings.seekIntervalSeconds seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode skipIntroMode = settings.skipIntroMode
speedUpCredits = settings.speedUpCredits speedUpCredits = settings.speedUpCredits
audioPassthroughMode = settings.audioPassthroughPreference.mode
audioPassthroughCodecs = settings.audioPassthroughPreference.codecs
ringColor = settings.ringColorHex ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet() homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity cardDensity = settings.homeCardDensity
@@ -426,6 +460,9 @@ fun SettingsSheet(
seekIntervalSeconds = seekInterval, seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode, skipIntroMode = skipIntroMode,
speedUpCredits = speedUpCredits, speedUpCredits = speedUpCredits,
audioPassthroughMode = audioPassthroughMode,
audioPassthroughCodecs = audioPassthroughCodecs,
detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough,
ringColor = ringColor, ringColor = ringColor,
homeSections = homeSections, homeSections = homeSections,
cardDensity = cardDensity, cardDensity = cardDensity,
@@ -482,6 +519,26 @@ fun SettingsSheet(
speedUpCredits = it speedUpCredits = it
scope.launch { store.setSpeedUpCredits(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 = { onRingColorChanged = {
ringColor = it ringColor = it
scope.launch { store.setRingColor(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 // 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 // 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. // 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( SettingsColourSchemeRow(
options = state.themeOptions, options = state.themeOptions,
selected = state.themeId, selected = state.themeId,
@@ -774,6 +837,38 @@ internal fun SettingsPanelContent(
) )
} }
SettingsPage.PLAYBACK -> SettingsGroup { 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( SettingsToggleRow(
title = "Ten minutes left", title = "Ten minutes left",
description = "A small reminder near the end of what you're watching.", description = "A small reminder near the end of what you're watching.",
@@ -956,7 +1051,7 @@ internal fun SettingsPanelContent(
Text( Text(
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...", "Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary, color = TextSecondary,
fontSize = 13.sp, fontSize = 12.sp,
) )
} }
Text( 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 package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.DeviceProfile 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.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -33,12 +34,19 @@ class PlaybackReportMathTest {
@Test @Test
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() { fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single() val baseline = DeviceProfile.embyAndroidTv(
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single() audio = DeviceAudioCapabilities(),
).directPlayProfiles.single()
val capable = DeviceProfile.embyAndroidTv(
capabilities = DevicePlaybackCapabilities(
hevc = VideoDecoderCapabilities(supported = true),
),
audio = DeviceAudioCapabilities(),
).directPlayProfiles.single()
assertEquals("h264", baseline.videoCodec) assertEquals("h264", baseline.videoCodec)
assertEquals("h264,hevc", capable.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.videoCodec.contains("av1"))
assertFalse(capable.audioCodec.contains("eac3")) assertFalse(capable.audioCodec.contains("eac3"))
} }
+7 -5
View File
@@ -1,10 +1,12 @@
// Top-level build file. Plugin versions are declared here and applied per-module. // Top-level build file. Plugin versions are declared here and applied per-module.
plugins { plugins {
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "9.3.1" apply false
id("com.android.test") version "8.13.2" apply false id("com.android.test") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.2.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false // A Kotlin compiler plugin, so its version tracks the compiler's, not its own release
// cadence — left behind at 2.0.21 by the 2.2.10 upgrade.
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.10" apply false
// Generates app/src/release/generated/baselineProfiles from the :benchmark journeys. // Generates app/src/release/generated/baselineProfiles from the :benchmark journeys.
// Keep this version in step with the androidx.benchmark artifacts in benchmark/. // Keep this version in step with the androidx.benchmark artifacts in benchmark/.
id("androidx.baselineprofile") version "1.3.4" apply false id("androidx.baselineprofile") version "1.3.4" apply false
+15
View File
@@ -19,3 +19,18 @@ memby.gatewayUrl=https://mserver.sublogue.com
# Enabled parallel sync for Gradle 9.4+ # Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true org.gradle.tooling.parallel=true
# Keeps Kotlin compilation with the JetBrains Gradle plugin rather than AGP 9's own
# built-in Kotlin, which is the default from AGP 9 and registers a `kotlin` extension of
# its own — applying org.jetbrains.kotlin.android on top of it fails outright. This is not
# upgrade cruft to be cleared later: Compose and kotlinx.serialization are *Kotlin compiler
# plugins* and need the JetBrains plugin to apply them, so built-in Kotlin cannot build
# this app until AGP carries both.
android.builtInKotlin=false
# Same constraint, one layer down. AGP 9's new DSL replaces BaseExtension, and Kotlin
# 2.2.10's Android plugin still casts the `android` extension to it — with the new DSL on,
# applying it fails with a ClassCastException before anything compiles. This is what makes
# `android { }` report itself deprecated in the build files; that warning cannot be
# resolved from this side, and clears when the Kotlin plugin adopts the new DSL.
android.newDsl=false
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+50
View File
@@ -481,6 +481,8 @@ func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
capabilities.HEVCHDR10Plus = true capabilities.HEVCHDR10Plus = true
case "video_hevc_dolby_vision": case "video_hevc_dolby_vision":
capabilities.HEVCDolbyVision = true capabilities.HEVCDolbyVision = true
case "audio_passthrough_v1":
capabilities.AudioProfileV1 = true
default: default:
parsePlaybackCapabilityValue(value, &capabilities) parsePlaybackCapabilityValue(value, &capabilities)
} }
@@ -508,6 +510,9 @@ func (s *Server) effectivePlaybackCapabilities(
} }
func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) { func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) {
if parseAudioCapability(value, capabilities) {
return
}
parseIntCapability(value, "video_h264_level_", &capabilities.H264Level) parseIntCapability(value, "video_h264_level_", &capabilities.H264Level)
parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level) parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level)
parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel) parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel)
@@ -520,6 +525,51 @@ func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapab
) )
} }
func parseAudioCapability(value string, capabilities *emby.PlaybackCapabilities) bool {
const prefix = "audio_"
if !strings.HasPrefix(value, prefix) {
return false
}
if strings.HasPrefix(value, "audio_max_channels_") {
parsed, err := strconv.Atoi(strings.TrimPrefix(value, "audio_max_channels_"))
if err == nil && parsed >= 2 && parsed <= 8 {
capabilities.AudioMaxChannels = parsed
}
return true
}
for _, suffix := range []string{"_passthrough", "_decode"} {
if !strings.HasSuffix(value, suffix) {
continue
}
codec := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)
if !validAudioCapabilityCodec(codec) {
return true
}
if suffix == "_passthrough" {
if capabilities.AudioPassthrough == nil {
capabilities.AudioPassthrough = map[string]bool{}
}
capabilities.AudioPassthrough[codec] = true
} else {
if capabilities.AudioDecode == nil {
capabilities.AudioDecode = map[string]bool{}
}
capabilities.AudioDecode[codec] = true
}
return true
}
return true
}
func validAudioCapabilityCodec(codec string) bool {
switch codec {
case "ac3", "eac3", "atmos", "dts", "dts_hd", "truehd":
return true
default:
return false
}
}
func parseIntCapability(value, prefix string, destination *int) { func parseIntCapability(value, prefix string, destination *int) {
if !strings.HasPrefix(value, prefix) { if !strings.HasPrefix(value, prefix) {
return return
@@ -0,0 +1,26 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestSessionPlaybackCapabilitiesParsesAudioEvidence(t *testing.T) {
capabilities := sessionPlaybackCapabilities(store.Session{ClientCapabilities: []string{
"audio_passthrough_v1",
"audio_eac3_passthrough",
"audio_truehd_decode",
"audio_max_channels_8",
"audio_unknown_passthrough",
}})
if !capabilities.AudioProfileV1 || !capabilities.AudioPassthrough["eac3"] {
t.Fatalf("passthrough evidence = %#v", capabilities)
}
if !capabilities.AudioDecode["truehd"] || capabilities.AudioMaxChannels != 8 {
t.Fatalf("decode evidence = %#v", capabilities)
}
if capabilities.AudioPassthrough["unknown"] {
t.Fatalf("unknown audio codec was accepted: %#v", capabilities.AudioPassthrough)
}
}
@@ -52,3 +52,43 @@ func TestForcedTranscodeProfileCannotStreamCopyHEVC(t *testing.T) {
} }
} }
} }
func TestAudioProfileAdvertisesDecodeAndPassthroughWithoutReencodingVideo(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
HEVC: true, AudioProfileV1: true, AudioMaxChannels: 8,
AudioPassthrough: map[string]bool{"ac3": true, "eac3": true},
AudioDecode: map[string]bool{
"ac3": true, "eac3": true, "atmos": true, "dts": true,
"dts_hd": true, "truehd": true,
},
})
direct := profile["DirectPlayProfiles"].([]map[string]string)[0]
for _, codec := range []string{"flac", "opus", "ac3", "eac3", "dts", "dtshd", "truehd"} {
if !containsCommaValue(direct["AudioCodec"], codec) {
t.Fatalf("direct audio codecs %q missing %q", direct["AudioCodec"], codec)
}
}
transcode := profile["TranscodingProfiles"].([]map[string]string)[0]
if transcode["VideoCodec"] != "h264,hevc" || transcode["AudioCodec"] != "eac3,ac3,aac,mp3" {
t.Fatalf("audio conversion profile = %#v", transcode)
}
profiles := profile["CodecProfiles"].([]map[string]any)
audio := profiles[len(profiles)-1]
conditions := audio["Conditions"].([]map[string]any)
if audio["Type"] != "VideoAudio" || conditions[0]["Value"] != "8" {
t.Fatalf("audio constraints = %#v", audio)
}
}
func containsCommaValue(values, wanted string) bool {
start := 0
for i := 0; i <= len(values); i++ {
if i == len(values) || values[i] == ',' {
if values[start:i] == wanted {
return true
}
start = i + 1
}
}
return false
}
+104 -18
View File
@@ -3,6 +3,7 @@
// //
// Wholphin: https://github.com/damontecres/Wholphin // Wholphin: https://github.com/damontecres/Wholphin
// Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv // Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
// Moonfin audio profile: https://github.com/Moonfin-Client/Moonfin-Core
// //
// Modifications Copyright (C) 2026 Memby contributors // Modifications Copyright (C) 2026 Memby contributors
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
@@ -10,49 +11,134 @@ package emby
import "strconv" import "strconv"
var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
}
// PlaybackCapabilities is the Android decoder evidence captured for one TV session. // PlaybackCapabilities is the Android decoder evidence captured for one TV session.
// Zero values deliberately describe the legacy H.264-safe profile. // Zero values deliberately describe the legacy H.264-safe profile.
type PlaybackCapabilities struct { type PlaybackCapabilities struct {
H264Profiles []string H264Profiles []string
H264Level int H264Level int
H264High10Level int H264High10Level int
H264MaxWidth int H264MaxWidth int
H264MaxHeight int H264MaxHeight int
HEVC bool HEVC bool
HEVCMain bool HEVCMain bool
HEVCMain10 bool HEVCMain10 bool
HEVCMainLevel int HEVCMainLevel int
HEVCMain10Level int HEVCMain10Level int
HEVCMaxWidth int HEVCMaxWidth int
HEVCMaxHeight int HEVCMaxHeight int
HEVCHDR10 bool HEVCHDR10 bool
HEVCHDR10Plus bool HEVCHDR10Plus bool
HEVCDolbyVision bool HEVCDolbyVision bool
AudioProfileV1 bool
AudioPassthrough map[string]bool
AudioDecode map[string]bool
AudioMaxChannels int
} }
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any { func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
videoCodecs := directPlayVideoCodecs(capabilities.HEVC) videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities)
return map[string]any{ return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video", "Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{ "DirectPlayProfiles": []map[string]string{
{ {
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs, "Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
"AudioCodec": "aac,mp3", "Type": "Video", "AudioCodec": audioCodecs, "Type": "Video",
}, },
}, },
"TranscodingProfiles": []map[string]string{ "TranscodingProfiles": []map[string]string{
{ {
// AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and // AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and
// convert only incompatible audio or subtitles into an HLS stream. // convert only incompatible audio or subtitles into an HLS stream.
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": "aac", "Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": transcodeAudio,
"Protocol": "hls", "Type": "Video", "Context": "Streaming", "Protocol": "hls", "Type": "Video", "Context": "Streaming",
}, },
}, },
"CodecProfiles": videoCodecProfiles(capabilities), "CodecProfiles": append(videoCodecProfiles(capabilities), audioCodecProfile(capabilities)),
"SubtitleProfiles": androidTVSubtitleProfiles(), "SubtitleProfiles": androidTVSubtitleProfiles(),
} }
} }
func directPlayAudioCodecs(capabilities PlaybackCapabilities) string {
if !capabilities.AudioProfileV1 {
return "aac,mp3"
}
codecs := append([]string{}, alwaysDecodableAudioCodecs...)
for _, codec := range []string{"ac3", "eac3", "atmos", "dts", "dts_hd", "truehd"} {
if capabilities.AudioPassthrough[codec] || capabilities.AudioDecode[codec] {
switch codec {
case "dts":
codecs = appendUnique(codecs, "dts", "dca")
case "dts_hd":
codecs = appendUnique(codecs, "dts", "dca", "dtshd")
case "truehd":
codecs = appendUnique(codecs, "truehd", "mlp")
case "atmos":
codecs = appendUnique(codecs, "eac3")
default:
codecs = appendUnique(codecs, codec)
}
}
}
return joinComma(codecs)
}
func transcodeAudioCodecs(capabilities PlaybackCapabilities) string {
codecs := []string{}
if capabilities.AudioPassthrough["eac3"] || capabilities.AudioPassthrough["atmos"] {
codecs = append(codecs, "eac3")
}
if capabilities.AudioPassthrough["ac3"] {
codecs = append(codecs, "ac3")
}
return joinComma(appendUnique(codecs, "aac", "mp3"))
}
func audioCodecProfile(capabilities PlaybackCapabilities) map[string]any {
channels := capabilities.AudioMaxChannels
if channels < 2 {
channels = 2
}
return map[string]any{
"Type": "VideoAudio", "Codec": "",
"Conditions": []map[string]any{
profileCondition("LessThanEqual", "AudioChannels", strconv.Itoa(channels)),
},
}
}
func appendUnique(values []string, additions ...string) []string {
for _, addition := range additions {
found := false
for _, value := range values {
if value == addition {
found = true
break
}
}
if !found {
values = append(values, addition)
}
}
return values
}
func joinComma(values []string) string {
result := ""
for _, value := range values {
if result != "" {
result += ","
}
result += value
}
return result
}
// forceH264TranscodeProfile turns decoder recovery into an actual codec change. Merely // forceH264TranscodeProfile turns decoder recovery into an actual codec change. Merely
// removing DirectPlayProfiles is insufficient: Emby may otherwise stream-copy HEVC into // removing DirectPlayProfiles is insufficient: Emby may otherwise stream-copy HEVC into
// the ordinary HLS transcoding profile and hand the failing decoder the same video again. // the ordinary HLS transcoding profile and hand the failing decoder the same video again.