From f41db66ac6a2e46f02d19f9b8e0483984a582b95 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 10 Aug 2026 12:23:23 +1200 Subject: [PATCH] 0.2.42 - DTS surround sound support --- CHANGELOG.md | 2 + CLAUDE.md | 36 ++- NOTICE | 17 ++ README.md | 13 +- app/build.gradle.kts | 20 +- app/src/main/AndroidManifest.xml | 13 + .../com/ponzischeme89/memby/ServiceLocator.kt | 4 + .../memby/data/EmbyRepository.kt | 19 +- .../ponzischeme89/memby/data/SettingsStore.kt | 54 ++++ .../memby/data/model/EmbyModels.kt | 50 +++- .../data/playback/AudioCapabilityProbe.kt | 142 ++++++++++ .../data/playback/DeviceAudioCapabilities.kt | 262 ++++++++++++++++++ .../data/remote/GatewayServiceFactory.kt | 31 ++- .../ponzischeme89/memby/ui/LaunchPreroll.kt | 115 ++++++-- .../ponzischeme89/memby/ui/MainActivity.kt | 98 +++++-- .../memby/ui/player/AudioPassthrough.kt | 90 ++++++ .../memby/ui/player/PlayerActivity.kt | 5 +- .../memby/ui/player/PlayerEngine.kt | 19 +- .../memby/ui/search/SearchScreen.kt | 48 +++- .../memby/ui/settings/SettingsSheet.kt | 103 ++++++- .../memby/data/DeviceAudioCapabilitiesTest.kt | 69 +++++ .../memby/data/PlaybackReportMathTest.kt | 14 +- build.gradle.kts | 12 +- gradle.properties | 15 + gradle/wrapper/gradle-wrapper.properties | 2 +- server/internal/api/playback.go | 50 ++++ server/internal/api/playback_audio_test.go | 26 ++ server/internal/emby/client_playback_test.go | 40 +++ server/internal/emby/device_profile.go | 122 ++++++-- 29 files changed, 1363 insertions(+), 128 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/playback/DeviceAudioCapabilities.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/DeviceAudioCapabilitiesTest.kt create mode 100644 server/internal/api/playback_audio_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb2989..88e1ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.2.41 — 2026-08-10 - Added: Memby's own opening clip now plays while the app is starting up. +- Added: Surround sound passthrough for Dolby Digital, Dolby Digital Plus, Dolby Atmos, DTS, DTS-HD and TrueHD, with automatic detection and manual receiver overrides. +- 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. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index d0fa3f6..dfaa46d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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. -Things to preserve: it is **decoration and never a gate**, so every failure is silent and -leaves the pulsing-logo screen exactly as it was, and the logo only fades out on -`onRenderedFirstFrame` rather than on having asked the player to start; 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; it is muted and looped, 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. And +**It now gates the launcher**, which is the whole point of it: it was decoration, uncovered +whenever the app happened to be ready, and on a warm start that was a fraction of a second — +so the one thing Memby owns was in practice never seen. The clip plays once from the +beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home +screen composed. Things to preserve: **it gates but it can never trap** — no player, a +decoder error, or no rendered frame within three seconds all report finished immediately, +and `LAUNCH_INTRO_MAX_MS` (9s, the clip's four plus the hold plus room for a weak box) is +`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 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 @@ -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 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** 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 diff --git a/NOTICE b/NOTICE index 2c9048f..1a00591 100644 --- a/NOTICE +++ b/NOTICE @@ -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 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 -------------------- @@ -38,6 +51,10 @@ source distributions and packaged dependency metadata. Those components remain governed by their own licences; GPLv2 applies to Memby's original 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 wrapper distributed under the MIT License: diff --git a/README.md b/README.md index 6d15757..4cbab8c 100644 --- a/README.md +++ b/README.md @@ -251,9 +251,12 @@ different app, so on every TV: - 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 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 - 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. + file. The Moonfin-derived audio path reports formats the TV can decode or bitstream, offers + automatic receiver detection and manual per-codec passthrough overrides, and decodes the + 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 server this is unnecessary but harmless. - 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 [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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62c1263..d180f9c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,10 +136,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true buildConfig = true @@ -173,6 +169,16 @@ android { } } +// Outside `android { }` on purpose: the Kotlin compiler's own settings belong to the Kotlin +// plugin, not to AGP's extension, which is why `kotlinOptions` inside it is deprecated. +// Keep in step with compileOptions above — a jvmTarget below the Java target fails the +// build the moment Kotlin has to read a class Java compiled. +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.12.01") implementation(composeBom) @@ -227,6 +233,12 @@ dependencies { // media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt. implementation("androidx.media3:media3-datasource-okhttp:1.5.1") + // Moonfin's Android TV backend keeps a software audio renderer behind Media3 so a + // surround track that is not bitstreamed is decoded to PCM instead of making the + // server re-encode the video beside it. This build targets Media3 1.5.x; Jellyfin's + // 1.5.0 extension is the matching published binary for that line. + implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1") + // Software-decoding fallback after Media3 exhausts its codec/container recovery. // Kept out of the normal path: Android TV still gets hardware decode, passthrough, // the full Memby OSD and the pre-roll from Media3 whenever the device can play it. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e42542..5cc24ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,12 @@ installer of record (Android 12+). Ignored before that, and never required: the install session falls back to asking. --> + + @@ -48,6 +54,13 @@ + + + if (forceTranscode) profile.h264TranscodeFallback() else profile }, ), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 240e01c..9b4d97f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -14,6 +14,10 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.ponzischeme89.memby.data.playback.AudioPassthroughMode +import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference +import com.ponzischeme89.memby.data.playback.SurroundCodec +import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch @@ -326,6 +330,18 @@ data class Settings( // Shrink the closing credits to one side at double speed with what is on next beside // them. Per-profile and synced for the same reason the three above are. val speedUpCredits: Boolean = true, + /** + * Whether surround formats are bitstreamed to the receiver by what the hardware probe + * reported, or by the viewer's own per-codec switches. + * + * Device-level, and deliberately not synced. What a soundbar accepts is a property of + * the room, not of the person sitting in it — pushing one lounge's DTS override onto + * every television in the house is exactly the failure the manual mode exists to fix. + * [audioPassthroughCodecs] is a comma-separated list of [SurroundCodec] slugs and is + * read only in manual mode. + */ + val audioPassthroughMode: String = DEFAULT_AUDIO_PASSTHROUGH_MODE, + val audioPassthroughCodecs: String = "", // Foreground colour of the slide-progress ring, as an RRGGBB hex string. val ringColorHex: String = DEFAULT_RING_COLOR, val lastBackdropUrl: String? = null, @@ -408,6 +424,7 @@ data class Settings( companion object { const val DEFAULT_ROTATION_SECONDS = 15 const val DEFAULT_RING_COLOR = "FFFFFF" + val DEFAULT_AUDIO_PASSTHROUGH_MODE = AudioPassthroughMode.DEFAULT.value const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest" const val DEFAULT_HOME_CARD_DENSITY = "standard" const val DEFAULT_HOME_ARTWORK_STYLE = "automatic" @@ -418,6 +435,20 @@ data class Settings( } } +/** + * The two stored keys read back as the one decision the player and the device profile ask + * for. Defined on a nullable receiver because most callers hold [SettingsStore.current], + * which is null until DataStore has emitted — and "not read yet" must mean automatic + * rather than manual with nothing chosen, which is manual with everything switched off. + */ +val Settings?.audioPassthroughPreference: AudioPassthroughPreference + get() = this?.let { + AudioPassthroughPreference( + mode = AudioPassthroughMode.from(it.audioPassthroughMode), + codecs = SurroundCodec.setFrom(it.audioPassthroughCodecs), + ) + } ?: AudioPassthroughPreference.AUTOMATIC + @Serializable data class EmbyProfile( val id: String, @@ -499,6 +530,8 @@ class SettingsStore(private val context: Context) { val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds") val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode") val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits") + val AUDIO_PASSTHROUGH_MODE = stringPreferencesKey("audio_passthrough_mode") + val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs") val RING_COLOR = stringPreferencesKey("ring_color") val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") val HOME_SECTIONS = stringPreferencesKey("home_sections") @@ -788,6 +821,24 @@ class SettingsStore(private val context: Context) { } } + /** + * Records how surround audio should reach the receiver on *this* television. + * + * Both halves are written in one edit. They describe one decision, and DataStore + * rewrites the whole file per edit — a mode landing apart from the switches it selects + * would leave a set momentarily in manual mode with nothing chosen, which is silence + * on every surround track it opens in that window. + */ + suspend fun setAudioPassthrough( + mode: AudioPassthroughMode, + codecs: Set, + ) { + context.dataStore.edit { preferences -> + preferences[Keys.AUDIO_PASSTHROUGH_MODE] = mode.value + preferences[Keys.AUDIO_PASSTHROUGH_CODECS] = codecs.asSlugs() + } + } + /** * Records the revision a successful push was stored under, without touching values. * Separate from [applyRemotePreferences] because after a push this TV already holds @@ -1363,6 +1414,9 @@ class SettingsStore(private val context: Context) { ), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]), speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, + audioPassthroughMode = AudioPassthroughMode + .from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value, + audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(), ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR, lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL], homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 4125f30..9c5fc4e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -1,8 +1,14 @@ package com.ponzischeme89.memby.data.model +import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference +import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities +import com.ponzischeme89.memby.data.playback.channelLimitFor +import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.embyAudioCodecs +import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -80,8 +86,17 @@ data class DeviceProfile( @SerialName("CodecProfiles") val codecProfiles: List = emptyList(), ) { companion object { + /** + * [audio] and [passthrough] are what stop an audio track being a reason to + * re-encode somebody's video. Every format this television can bitstream *or* + * decode is listed as direct-playable, so Emby only ever converts a track it has + * genuinely no other way to deliver — and when it does, the video beside it is + * copied rather than sent through an encoder. + */ fun embyAndroidTv( capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities, + audio: DeviceAudioCapabilities = deviceAudioCapabilities, + passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC, ) = DeviceProfile( name = "Memby Android TV", subtitleProfiles = listOf( @@ -94,7 +109,7 @@ data class DeviceProfile( // The broad codec declaration is bounded by CodecProfiles below. container = "mkv,mp4,m4v,mov,ts,mpegts", videoCodec = directPlayVideoCodecs(capabilities), - audioCodec = "aac,mp3", + audioCodec = audio.embyAudioCodecs(passthrough), ), ), transcodingProfiles = listOf( @@ -103,11 +118,13 @@ data class DeviceProfile( // Permits Emby to remux a supported HEVC/H.264 video stream while // converting only incompatible audio or subtitles. videoCodec = directPlayVideoCodecs(capabilities), - audioCodec = "aac", + // A set that can bitstream Dolby asks for Dolby, so a 5.1 track that + // genuinely has to be converted does not arrive as stereo AAC. + audioCodec = audio.transcodeAudioCodecs(passthrough), protocol = "hls", ), ), - codecProfiles = codecProfiles(capabilities), + codecProfiles = codecProfiles(capabilities) + audioCodecProfiles(audio, passthrough), ) /** Compatibility for callers and older tests that only know the HEVC boolean. */ @@ -123,6 +140,33 @@ data class DeviceProfile( private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String = if (capabilities.hevc.supported) "h264,hevc" else "h264" + /** + * How many channels Emby may send. `VideoAudio` with no codec applies the limit to + * every audio track in a video file, which is what it is for — the constraint is + * the cable and the receiver, not any one format. + * + * A `MaxAudioChannels` of the full bitstream width is only claimed once something + * is actually being bitstreamed. With passthrough off, this television is the thing + * decoding, and promising eight channels it would then downmix itself only costs + * bandwidth on every stream. + */ + private fun audioCodecProfiles( + audio: DeviceAudioCapabilities, + passthrough: AudioPassthroughPreference, + ): List = listOf( + CodecProfile( + type = "VideoAudio", + codec = "", + conditions = listOf( + ProfileCondition( + "LessThanEqual", + "AudioChannels", + audio.channelLimitFor(passthrough).toString(), + ), + ), + ), + ) + private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List = buildList { addVideoProfiles("h264", capabilities.h264) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt b/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt new file mode 100644 index 0000000..2c2bf36 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/playback/AudioCapabilityProbe.kt @@ -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) { + AudioProbeHolder.cached = null + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/playback/DeviceAudioCapabilities.kt b/app/src/main/java/com/ponzischeme89/memby/data/playback/DeviceAudioCapabilities.kt new file mode 100644 index 0000000..70c473e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/playback/DeviceAudioCapabilities.kt @@ -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, + val decodeMimeTypes: List = 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 = slugs.split(',') + .mapNotNull(::from) + .toSet() + + fun Set.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 = emptySet(), + val decode: Set = 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 = 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 = 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.withCarrierCodecs(): Set = 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 = 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 = 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)}") +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 53169b1..c24d0f2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -2,7 +2,11 @@ package com.ponzischeme89.memby.data.remote import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.ponzischeme89.memby.BuildConfig +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.audioPassthroughPreference +import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities +import com.ponzischeme89.memby.data.playback.gatewayAudioTokens import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens import kotlinx.serialization.json.Json import okhttp3.Interceptor @@ -57,14 +61,33 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : .header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString()) .header( "X-Memby-Capabilities", - (MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens()) - .joinToString(","), + ( + MEMBY_CAPABILITIES + + devicePlaybackCapabilities.gatewayCapabilityTokens() + + audioCapabilityTokens() + ).joinToString(","), ) tokenProvider()?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", "Bearer $it") } return chain.proceed(builder.build()) } + + /** + * What this set can put through its speakers, resolved against the viewer's own + * passthrough choice — so a manual override reaches the *server's* device profile too, + * not only the audio sink. Read on each request rather than captured, because the + * override can be changed in Settings without restarting the process. + * + * It is guarded because this interceptor also runs before the service locator exists + * in a screenshot or instrumentation context, where an exception here would fail the + * request rather than merely describe the television modestly. + */ + private fun audioCapabilityTokens(): List = runCatching { + deviceAudioCapabilities.gatewayAudioTokens( + ServiceLocator.settings.current.audioPassthroughPreference, + ) + }.getOrDefault(emptyList()) } internal const val MEMBY_PROTOCOL_VERSION = 1 @@ -84,6 +107,10 @@ internal val MEMBY_CAPABILITIES = listOf( // Declares that this build can offer to skip an episode's opening titles, so the admin // console reports the feature honestly against an older app that would never ask. "skip_intro_v1", + // Declares that this build reports what its receiver accepts and can be sent a stream + // whose audio it will bitstream. A gateway seeing this knows the audio tokens beside + // it are a complete answer rather than an older app that simply never described itself. + "audio_passthrough_v1", ) internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt index e61bcff..7fbb077 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt @@ -11,10 +11,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -22,31 +22,58 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.ponzischeme89.memby.ui.player.PrerollPreloader +import kotlinx.coroutines.delay + +/** How long the last frame is held after the clip ends, before the launcher is uncovered. */ +internal const val LAUNCH_INTRO_HOLD_MS = 2_000L /** - * Memby's own short clip, played behind the cold-start screen. + * The longest the launcher may ever be held back by the opening clip. * - * The clip was already prepared during launch and already borrowed by the player for a - * fresh playback's pre-roll; the one place it was never seen was the screen every launch - * shows. This is that screen — the same 200 KB local resource, the same process-cached - * [PrerollPreloader] instance, no second decoder and no second copy of the file. + * The clip is four seconds and the hold is two, so this is that plus room for a slow + * decoder on a weak box. It is the outer guarantee: whatever happens to the player, the + * viewer reaches their rows. + */ +internal const val LAUNCH_INTRO_MAX_MS = 9_000L + +/** How long a borrowed player has to put a frame on screen before it is given up on. */ +private const val LAUNCH_INTRO_FIRST_FRAME_MS = 3_000L + +/** + * Whether this process has already played the opening clip. + * + * Process-scoped rather than persisted: the intro belongs to opening the app, and a + * television that has been sitting on the launcher all evening has already had it. It is + * also what stops the clip gating any *later* appearance of the loading screen — a profile + * switch is not an app launch. + */ +internal object LaunchIntro { + var played: Boolean = false +} + +/** + * Memby's own short clip, played over the cold-start screen while the app opens. + * + * The clip used to be decoration: it looped quietly behind the "opening" text and the + * launcher uncovered it whenever it happened to be ready, which on a warm start was a + * fraction of a second — so the one thing Memby owns was, in practice, never seen. It now + * **gates the launcher**: it plays once from the beginning, its last frame is held for + * [LAUNCH_INTRO_HOLD_MS], and only then is the home screen composed. * * Things worth preserving: * - * - **It is decoration and never a gate.** Every failure path is silent and leaves the - * pulsing-logo screen exactly as it was: an unavailable player, a decoder error, a set - * that cannot render the clip at all. Nothing about opening the launcher waits on it, and - * [onVisible] is called only once a frame has actually been drawn — so the fade never + * - **It gates, but it can never trap.** Every failure — no player, a decoder error, a set + * that renders no frame within [LAUNCH_INTRO_FIRST_FRAME_MS] — reports itself finished + * immediately, and [LAUNCH_INTRO_MAX_MS] in `AppRoot` is the outer bound on top of that. + * A viewer must never be held on a black screen by a branding clip. + * - **[onVisible] is the first *rendered* frame**, not the play call, so the fade never * uncovers a black rectangle. - * - **It is muted.** A branding sting is written to run its length; the cold-start screen is - * commonly gone in a few hundred milliseconds, and a sound cut off a third of the way - * through on every single app open is worse than no sound. The clip keeps its audio where - * it plays to the end, which is the pre-roll before a programme. - * - **It loops.** A cold start on a slow connection outlasts six seconds, and a clip that - * ended would leave its last frame frozen under the "opening" text — which reads as the - * television having hung at precisely the moment the viewer is watching for that. - * [PrerollPreloader.acquire] puts the repeat mode back, so the pre-roll before a - * programme still ends. + * - **It does not loop any more.** Looping existed so a slow cold start never froze on the + * last frame; the hold and the fade-out do that job now, and a clip that never ends + * cannot gate anything. + * - **It is muted.** The launcher's own clip plays on every single app open, and a set that + * chimes each time it is switched on wears out fast. The pre-roll before a programme + * keeps its audio — [PrerollPreloader.acquire] puts the volume back. * - **The player is returned, not released.** It goes back to the process cache on dispose, * which is what lets the very next playback still open on a prepared instance. */ @@ -55,44 +82,59 @@ import com.ponzischeme89.memby.ui.player.PrerollPreloader internal fun LaunchPrerollVideo( modifier: Modifier = Modifier, onVisible: () -> Unit, + onFinished: () -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val currentOnVisible by rememberUpdatedState(onVisible) + val currentOnFinished by rememberUpdatedState(onFinished) // Deliberately *not* borrowed during composition. On a cold start there is nothing // cached yet, so acquiring here would construct an ExoPlayer and open the local // resource on the main thread inside the first composition of the one screen whose - // whole job is to appear immediately. Waiting a frame costs the clip a few tens of - // milliseconds nobody can see behind the fade, and costs the launcher nothing. + // whole job is to appear immediately. var player by remember { mutableStateOf(null) } LaunchedEffect(Unit) { withFrameNanos { } - player = runCatching { PrerollPreloader.acquire(context) }.getOrNull() + val acquired = runCatching { PrerollPreloader.acquire(context) }.getOrNull() + if (acquired == null) currentOnFinished() else player = acquired } - // A null is an ordinary outcome — the caller simply keeps the screen it already has. + // A null is an ordinary outcome — the caller has already been told to carry on. val active = player ?: return + var rendered by remember(active) { mutableStateOf(false) } + var completed by remember(active) { mutableStateOf(false) } + var failed by remember(active) { mutableStateOf(false) } + DisposableEffect(active, lifecycleOwner) { val listener = object : Player.Listener { - override fun onRenderedFirstFrame() = currentOnVisible() + override fun onRenderedFirstFrame() { + rendered = true + currentOnVisible() + } + + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_ENDED) completed = true + } override fun onPlayerError(error: PlaybackException) { - // Nothing to say and nowhere to say it. The screen underneath is complete. + // Nothing to say and nowhere to say it. The screen underneath is complete, + // and the launcher must not wait on a clip that has stopped. active.playWhenReady = false + failed = true } } val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_STOP -> active.playWhenReady = false - Lifecycle.Event.ON_START -> active.playWhenReady = true + Lifecycle.Event.ON_START -> if (!completed) active.playWhenReady = true else -> Unit } } active.addListener(listener) lifecycleOwner.lifecycle.addObserver(observer) active.volume = 0f - active.repeatMode = Player.REPEAT_MODE_ONE + active.repeatMode = Player.REPEAT_MODE_OFF active.seekTo(0L) if (active.playbackState == Player.STATE_IDLE) active.prepare() active.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) @@ -104,6 +146,25 @@ internal fun LaunchPrerollVideo( } } + // A set that cannot draw the clip at all is indistinguishable, from here, from one that + // is simply slow — so give it a bounded chance and then get out of the way. + LaunchedEffect(active) { + delay(LAUNCH_INTRO_FIRST_FRAME_MS) + if (!rendered) failed = true + } + + LaunchedEffect(completed, failed) { + when { + failed -> currentOnFinished() + completed -> { + // The pause the whole feature is for: the mark is on screen, still, long + // enough to have been looked at rather than glimpsed. + delay(LAUNCH_INTRO_HOLD_MS) + currentOnFinished() + } + } + } + AndroidView( modifier = modifier, factory = { viewContext -> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 8614aea..681a0ae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -253,6 +253,19 @@ private fun AppRoot(onCloseSettings: () -> Unit) { val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } var appUpdate by remember { mutableStateOf(null) } var initialUpdateCheckComplete by remember { mutableStateOf(false) } + // Held for the length of Memby's opening clip plus its pause, once per process. Not a + // rememberSaveable: an activity recreated behind the viewer (returning from the TV home + // screen, a configuration change) is not an app launch, and LaunchIntro carries that + // across it. + var introHolding by remember { mutableStateOf(!LaunchIntro.played) } + LaunchedEffect(Unit) { + if (!introHolding) return@LaunchedEffect + // The clip is decoration; the rows are the app. Whatever the player is doing, this + // is the longest it may ever stand in front of them. + kotlinx.coroutines.delay(LAUNCH_INTRO_MAX_MS) + LaunchIntro.played = true + introHolding = false + } var dismissedUpdateVersion by rememberSaveable { mutableStateOf(null) } var confirmingExit by rememberSaveable { mutableStateOf(false) } // SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when @@ -397,6 +410,12 @@ private fun AppRoot(onCloseSettings: () -> Unit) { // it a moment later, twice, during the one stretch of a launch that is busiest. // Hoisted to one call site, the screen and its clip survive the whole cold start. val openingQuoteStyle = when { + // Memby's own clip runs to its end before anything else is drawn. It is the one + // thing the app owns and it was, in practice, never seen: the launcher uncovered + // it whenever it happened to be ready, which on a warm start was a fraction of a + // second. LAUNCH_INTRO_MAX_MS below is the outer bound — the rows are never more + // than that away, whatever the player does. + introHolding -> loaded?.welcomeQuoteStyle.orEmpty() !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty() appUpdate != null -> null loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty() @@ -408,7 +427,15 @@ private fun AppRoot(onCloseSettings: () -> Unit) { loaded.welcomeQuoteStyle.orEmpty() else -> null } - if (openingQuoteStyle != null) MembyLoadingScreen(quoteStyle = openingQuoteStyle) + if (openingQuoteStyle != null) { + MembyLoadingScreen( + quoteStyle = openingQuoteStyle, + onIntroFinished = { + LaunchIntro.played = true + introHolding = false + }, + ) + } when { openingQuoteStyle != null -> Unit appUpdate != null -> UpdateScreen( @@ -568,17 +595,38 @@ private fun ExitMembyConfirmation( @androidx.media3.common.util.UnstableApi @Composable -private fun MembyLoadingScreen(quoteStyle: String? = null) { +private fun MembyLoadingScreen( + quoteStyle: String? = null, + onIntroFinished: () -> Unit = {}, +) { val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } // Whether Memby's own clip is actually on screen behind this. Set from the player's // first rendered frame rather than from having asked it to play, so the logo only gets // out of the way once there is something to get out of the way for. var prerollVisible by remember { mutableStateOf(false) } + // The clip has played its length and been held. Everything below it comes back. + var introDone by remember { mutableStateOf(false) } + // ...and a moment later the video node goes, which is what hands the player back to the + // process cache. Removing it on the same frame as the fade would cut to black. + var introRemoved by remember { mutableStateOf(false) } val prerollAlpha by animateFloatAsState( - targetValue = if (prerollVisible) 1f else 0f, + targetValue = if (prerollVisible && !introDone) 1f else 0f, animationSpec = tween(420), label = "cold-start-preroll-alpha", ) + // While the clip runs it is the whole screen: the pulsing mark and the welcome line are + // the *waiting* screen, and printing them over a four-second sting says two things at + // once. They fade in behind it if the app is still opening when it ends. + val chromeAlpha by animateFloatAsState( + targetValue = if (prerollVisible && !introDone) 0f else 1f, + animationSpec = tween(420), + label = "cold-start-chrome-alpha", + ) + LaunchedEffect(introDone) { + if (!introDone) return@LaunchedEffect + kotlinx.coroutines.delay(460L) + introRemoved = true + } // Not keyed on the quote style: the headline says what the app is doing, and rerolling // it when the settings flow arrives with a tone would change the line under the viewer // mid-launch. Once per appearance of this screen is the intent. @@ -614,34 +662,30 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) { ), contentAlignment = Alignment.Center, ) { - LaunchPrerollVideo( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = prerollAlpha }, - onVisible = { prerollVisible = true }, - ) - // Holds the copy legible over whatever frame the clip happens to be on. It fades in - // with the video rather than sitting there over the plain gradient, where it would - // only be darkening a screen that is already dark. - Box( - Modifier - .fillMaxSize() - .graphicsLayer { alpha = prerollAlpha } - .background( - Brush.verticalGradient( - colors = listOf( - MembySurface.copy(alpha = 0.35f), - MembySurface.copy(alpha = 0.82f), - ), - ), - ), - ) + if (!introRemoved) { + LaunchPrerollVideo( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = prerollAlpha }, + onVisible = { prerollVisible = true }, + onFinished = { + if (!introDone) { + introDone = true + onIntroFinished() + } + }, + ) + } + // The scrim that used to hold this copy legible over the clip is gone with the + // overlap it existed for: the waiting screen and the clip no longer share the frame, + // so darkening Memby's own mark by four fifths would be for nobody's benefit. Column( + modifier = Modifier.graphicsLayer { alpha = chromeAlpha }, verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // The clip is Memby's own mark moving; the still logo above it would be the - // same thing said twice, so it gives way as the video arrives. + // same thing said twice, so the whole column gives way while it runs. Image( painter = painterResource(R.drawable.emby_logo), contentDescription = "Memby", @@ -651,7 +695,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) { .graphicsLayer { scaleX = pulse scaleY = pulse - alpha = (0.82f + (glow * 0.18f)) * (1f - prerollAlpha) + alpha = 0.82f + (glow * 0.18f) }, ) Text( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt new file mode 100644 index 0000000..6e423c8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/AudioPassthrough.kt @@ -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) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 7082b53..ed69136 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -59,6 +59,7 @@ import coil.load import coil.request.ImageRequest import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.audioPassthroughPreference import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.IntroSegment import com.ponzischeme89.memby.data.creditsWorthShowing @@ -491,7 +492,9 @@ class PlayerActivity : ComponentActivity() { // queue behind can be seen until the first frame lands anyway. Nothing here can // race the listener either — media3 posts its callbacks to this thread, so the // first one cannot arrive until onCreate has returned. - player = PlayerEngine.create(this) + // Surround passthrough is settled before the sink is built, not after: an audio + // sink cannot change its mind about bitstreaming a format once a track is open. + player = PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference) .also { playback -> view.player = playback trace.mark(PlaybackTrace.PLAYER_BUILT) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt index 8d4bb61..794d3b2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt @@ -12,6 +12,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory +import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference import com.ponzischeme89.memby.data.remote.HttpStack import java.util.concurrent.TimeUnit @@ -32,14 +33,26 @@ import java.util.concurrent.TimeUnit @UnstableApi internal object PlayerEngine { - fun create(context: Context): ExoPlayer = ExoPlayer.Builder(context) + /** + * [audio] decides whether a surround track is bitstreamed to the receiver or decoded + * here into PCM. It defaults to automatic, which is also the right answer for the + * pre-roll: that clip is stereo AAC, and it borrows this same builder. + */ + fun create( + context: Context, + audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC, + ): ExoPlayer = ExoPlayer.Builder(context) .setMediaSourceFactory(mediaSourceFactory(context)) .setRenderersFactory( - DefaultRenderersFactory(context) + MembyRenderersFactory(context, audio) // Some Android TV firmwares advertise a preferred hardware decoder which // fails only after initialization. Let Media3 try another installed decoder // before declaring the file unsupported. - .setEnableDecoderFallback(true), + .setEnableDecoderFallback(true) + // The bundled FFmpeg audio renderer sits after platform decoders. It is + // reached only when Android cannot decode a surround format itself; its + // output is PCM, so passthrough-capable tracks still bypass it untouched. + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON), ) .setTrackSelector(DefaultTrackSelector(context)) .setLoadControl(loadControl()) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 1f8f8c2..bcae20b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -2,10 +2,13 @@ package com.ponzischeme89.memby.ui.search +import android.Manifest import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.speech.RecognizerIntent import android.speech.SpeechRecognizer +import androidx.core.content.ContextCompat import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -379,6 +382,31 @@ private fun QueryField( if (!spoken.isNullOrEmpty()) onVoiceResult(spoken) } + val startVoiceSearch = { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title") + } + // A device can advertise recognition and still have nothing to launch; failing + // silently beats crashing the search screen. + runCatching { voiceLauncher.launch(intent) } + Unit + } + + // The recogniser is asked for either way. Its own activity holds the mic, so a viewer + // who declines here is simply back to the behaviour this had before the prompt existed + // — which works on plenty of devices — and refusing to open it would take away a + // capability they may still have. What the grant buys is the devices that quietly hand + // back an empty result instead, where the failure looks like Memby's rather than like a + // permission nobody was ever asked for. A "don't ask again" denial returns immediately, + // so this never becomes a dialog between the viewer and the microphone button. + val micPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { startVoiceSearch() } + Row( modifier = Modifier .fillMaxWidth() @@ -416,16 +444,18 @@ private fun QueryField( FocusScaleContainer( onFocused = {}, onClick = { - val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, - ) - putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title") + // Asked at the point of use rather than during setup: the microphone is + // only ever wanted by this one button, and a permission dialog in front + // of a new television is one nobody can connect to anything. + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + startVoiceSearch() + } else { + runCatching { micPermission.launch(Manifest.permission.RECORD_AUDIO) } } - // A device can advertise recognition and still have nothing to - // launch; failing silently beats crashing the search screen. - runCatching { voiceLauncher.launch(intent) } }, contentDescription = "Search by voice", modifier = Modifier.clip(RoundedCornerShape(8.dp)), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 9b35c1c..2bc090e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -89,6 +89,10 @@ import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE import com.ponzischeme89.memby.data.ImageCacheMaintenance +import com.ponzischeme89.memby.data.audioPassthroughPreference +import com.ponzischeme89.memby.data.playback.AudioPassthroughMode +import com.ponzischeme89.memby.data.playback.SurroundCodec +import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities import com.ponzischeme89.memby.data.ImageCacheSize import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO @@ -123,8 +127,8 @@ internal data class ChoiceOption(val value: String, val label: String, val color private val RingOptions = listOf( ChoiceOption("FFFFFF", "White", Color.White), - ChoiceOption("52B54B", "Memby green", Color(0xFF52B54B)), - ChoiceOption("E50914", "Cinema red", Color(0xFFE50914)), + ChoiceOption("52B54B", "Emby Green", Color(0xFF52B54B)), + ChoiceOption("E50914", "Netflix Red", Color(0xFFE50914)), ) private val DensityOptions = listOf( @@ -156,6 +160,21 @@ private val WelcomeOptions = WelcomeQuoteStyle.entries.map { ChoiceOption(it.value, it.label) } +private val AudioPassthroughOptions = listOf( + ChoiceOption(AudioPassthroughMode.AUTO.value, "Auto"), + ChoiceOption(AudioPassthroughMode.MANUAL.value, "Manual"), +) + +/** + * Whether Settings → Appearance offers the colour-scheme picker. + * + * Off: choosing a scheme does not reliably repaint the app yet, and a control that appears + * to do nothing is read as a fault in the television. Seasonal themes, the synced `themeId` + * preference and the palette plumbing are all unaffected — this hides the question, it does + * not remove the answer. + */ +private const val THEME_PICKER_ENABLED = false + internal enum class SettingsPage( val label: String, val description: String, @@ -208,6 +227,9 @@ internal data class SettingsPanelState( val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, val speedUpCredits: Boolean = true, + val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO, + val audioPassthroughCodecs: Set = emptySet(), + val detectedAudioPassthroughCodecs: Set = emptySet(), val ringColor: String = "52B54B", val homeSections: Set = setOf("continue", "favorites", "latest"), val cardDensity: String = "standard", @@ -265,6 +287,8 @@ internal data class SettingsPanelActions( val onSeekIntervalChanged: (Int) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {}, val onSpeedUpCreditsChanged: (Boolean) -> Unit = {}, + val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {}, + val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> }, val onRingColorChanged: (String) -> Unit = {}, val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> }, val onCardDensityChanged: (String) -> Unit = {}, @@ -315,6 +339,12 @@ fun SettingsSheet( var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) } + var audioPassthroughMode by remember { + mutableStateOf(settings.audioPassthroughPreference.mode) + } + var audioPassthroughCodecs by remember { + mutableStateOf(settings.audioPassthroughPreference.codecs) + } var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) } var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } @@ -392,6 +422,8 @@ fun SettingsSheet( settings.seekIntervalSeconds, settings.skipIntroMode, settings.speedUpCredits, + settings.audioPassthroughMode, + settings.audioPassthroughCodecs, settings.welcomeQuoteStyle, ) { showLogo = settings.showTitleLogo @@ -400,6 +432,8 @@ fun SettingsSheet( seekInterval = settings.seekIntervalSeconds skipIntroMode = settings.skipIntroMode speedUpCredits = settings.speedUpCredits + audioPassthroughMode = settings.audioPassthroughPreference.mode + audioPassthroughCodecs = settings.audioPassthroughPreference.codecs ringColor = settings.ringColorHex homeSections = settings.homeSections.split(',').toSet() cardDensity = settings.homeCardDensity @@ -426,6 +460,9 @@ fun SettingsSheet( seekIntervalSeconds = seekInterval, skipIntroMode = skipIntroMode, speedUpCredits = speedUpCredits, + audioPassthroughMode = audioPassthroughMode, + audioPassthroughCodecs = audioPassthroughCodecs, + detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough, ringColor = ringColor, homeSections = homeSections, cardDensity = cardDensity, @@ -482,6 +519,26 @@ fun SettingsSheet( speedUpCredits = it scope.launch { store.setSpeedUpCredits(it) } }, + onAudioPassthroughModeChanged = { mode -> + audioPassthroughMode = mode + // Moonfin seeds a first manual visit from the live probe. That makes Manual + // start as an editable copy of Auto instead of unexpectedly switching every + // surround format off the moment the viewer opens it. + if (mode == AudioPassthroughMode.MANUAL && audioPassthroughCodecs.isEmpty()) { + audioPassthroughCodecs = deviceAudioCapabilities.passthrough + } + scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) } + }, + onAudioPassthroughCodecChanged = { codec, enabled -> + audioPassthroughCodecs = if (enabled) { + audioPassthroughCodecs + codec + } else { + audioPassthroughCodecs - codec + } + scope.launch { + store.setAudioPassthrough(audioPassthroughMode, audioPassthroughCodecs) + } + }, onRingColorChanged = { ringColor = it scope.launch { store.setRingColor(it) } @@ -728,7 +785,13 @@ internal fun SettingsPanelContent( // the direct path, on an older gateway, and for a viewer the operator // has left with one scheme, there is no question to ask — and a row of // one chip that cannot be moved is worse than no row. - if (state.themeOptions.size > 1) { + // + // Withheld outright while THEME_PICKER_ENABLED is false: a choice that + // does not visibly take effect is worse than no choice, because the + // viewer's conclusion is that the television is broken rather than that + // the feature is unfinished. Everything behind it — the preference, the + // sync, the palette — is untouched, so this is one flag to put back. + if (THEME_PICKER_ENABLED && state.themeOptions.size > 1) { SettingsColourSchemeRow( options = state.themeOptions, selected = state.themeId, @@ -774,6 +837,38 @@ internal fun SettingsPanelContent( ) } SettingsPage.PLAYBACK -> SettingsGroup { + SettingsChoiceRow( + title = "Surround sound passthrough", + description = "Auto follows the television and receiver. Manual lets you correct a device that reports the wrong formats.", + options = AudioPassthroughOptions, + selected = state.audioPassthroughMode.value, + onSelected = { value -> + actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value)) + }, + ) + if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) { + SurroundCodec.entries.forEach { codec -> + SettingDivider() + SettingsToggleRow( + title = codec.label, + description = buildString { + append(codec.description) + append( + if (codec in state.detectedAudioPassthroughCodecs) { + " Detected on this audio output." + } else { + " Not detected; enable only if your receiver accepts it." + }, + ) + }, + checked = codec in state.audioPassthroughCodecs, + onCheckedChange = { enabled -> + actions.onAudioPassthroughCodecChanged(codec, enabled) + }, + ) + } + } + SettingDivider() SettingsToggleRow( title = "Ten minutes left", description = "A small reminder near the end of what you're watching.", @@ -956,7 +1051,7 @@ internal fun SettingsPanelContent( Text( "Memby (Matt's Emby) is built for Android TV, because the default client sucks...", color = TextSecondary, - fontSize = 13.sp, + fontSize = 12.sp, ) } Text( diff --git a/app/src/test/java/com/ponzischeme89/memby/data/DeviceAudioCapabilitiesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/DeviceAudioCapabilitiesTest.kt new file mode 100644 index 0000000..1284ee3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/DeviceAudioCapabilitiesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt index e075c01..b81102d 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.DeviceProfile +import com.ponzischeme89.memby.data.playback.DeviceAudioCapabilities import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities import org.junit.Assert.assertEquals @@ -33,12 +34,19 @@ class PlaybackReportMathTest { @Test fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() { - val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single() - val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single() + val baseline = DeviceProfile.embyAndroidTv( + audio = DeviceAudioCapabilities(), + ).directPlayProfiles.single() + val capable = DeviceProfile.embyAndroidTv( + capabilities = DevicePlaybackCapabilities( + hevc = VideoDecoderCapabilities(supported = true), + ), + audio = DeviceAudioCapabilities(), + ).directPlayProfiles.single() assertEquals("h264", baseline.videoCodec) assertEquals("h264,hevc", capable.videoCodec) - assertEquals("aac,mp3", capable.audioCodec) + assertEquals("aac,mp3,flac,opus,vorbis,pcm_s16le,pcm_s24le", capable.audioCodec) assertFalse(capable.videoCodec.contains("av1")) assertFalse(capable.audioCodec.contains("eac3")) } diff --git a/build.gradle.kts b/build.gradle.kts index a01fd2b..79b8c14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,12 @@ // Top-level build file. Plugin versions are declared here and applied per-module. plugins { - id("com.android.application") version "8.13.2" apply false - id("com.android.test") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false + id("com.android.application") version "9.3.1" apply false + id("com.android.test") version "9.3.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.10" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" 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. // Keep this version in step with the androidx.benchmark artifacts in benchmark/. id("androidx.baselineprofile") version "1.3.4" apply false diff --git a/gradle.properties b/gradle.properties index 12b9321..a62ef07 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,18 @@ memby.gatewayUrl=https://mserver.sublogue.com # Enabled parallel sync for Gradle 9.4+ 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..1a70468 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME 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 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index b1f041d..f10b9d4 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -481,6 +481,8 @@ func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities { capabilities.HEVCHDR10Plus = true case "video_hevc_dolby_vision": capabilities.HEVCDolbyVision = true + case "audio_passthrough_v1": + capabilities.AudioProfileV1 = true default: parsePlaybackCapabilityValue(value, &capabilities) } @@ -508,6 +510,9 @@ func (s *Server) effectivePlaybackCapabilities( } func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) { + if parseAudioCapability(value, capabilities) { + return + } parseIntCapability(value, "video_h264_level_", &capabilities.H264Level) parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level) 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) { if !strings.HasPrefix(value, prefix) { return diff --git a/server/internal/api/playback_audio_test.go b/server/internal/api/playback_audio_test.go new file mode 100644 index 0000000..e0214f6 --- /dev/null +++ b/server/internal/api/playback_audio_test.go @@ -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) + } +} diff --git a/server/internal/emby/client_playback_test.go b/server/internal/emby/client_playback_test.go index e1a272c..0ae6a52 100644 --- a/server/internal/emby/client_playback_test.go +++ b/server/internal/emby/client_playback_test.go @@ -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 +} diff --git a/server/internal/emby/device_profile.go b/server/internal/emby/device_profile.go index 901a865..dc8e591 100644 --- a/server/internal/emby/device_profile.go +++ b/server/internal/emby/device_profile.go @@ -3,6 +3,7 @@ // // Wholphin: https://github.com/damontecres/Wholphin // 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 // SPDX-License-Identifier: GPL-2.0-only @@ -10,49 +11,134 @@ package emby 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. // Zero values deliberately describe the legacy H.264-safe profile. type PlaybackCapabilities struct { - H264Profiles []string - H264Level int - H264High10Level int - H264MaxWidth int - H264MaxHeight int - HEVC bool - HEVCMain bool - HEVCMain10 bool - HEVCMainLevel int - HEVCMain10Level int - HEVCMaxWidth int - HEVCMaxHeight int - HEVCHDR10 bool - HEVCHDR10Plus bool - HEVCDolbyVision bool + H264Profiles []string + H264Level int + H264High10Level int + H264MaxWidth int + H264MaxHeight int + HEVC bool + HEVCMain bool + HEVCMain10 bool + HEVCMainLevel int + HEVCMain10Level int + HEVCMaxWidth int + HEVCMaxHeight int + HEVCHDR10 bool + HEVCHDR10Plus bool + HEVCDolbyVision bool + AudioProfileV1 bool + AudioPassthrough map[string]bool + AudioDecode map[string]bool + AudioMaxChannels int } func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any { videoCodecs := directPlayVideoCodecs(capabilities.HEVC) + audioCodecs := directPlayAudioCodecs(capabilities) + transcodeAudio := transcodeAudioCodecs(capabilities) return map[string]any{ "Name": "Memby Android TV", "SupportedMediaTypes": "Video", "DirectPlayProfiles": []map[string]string{ { "Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs, - "AudioCodec": "aac,mp3", "Type": "Video", + "AudioCodec": audioCodecs, "Type": "Video", }, }, "TranscodingProfiles": []map[string]string{ { // AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and // 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", }, }, - "CodecProfiles": videoCodecProfiles(capabilities), + "CodecProfiles": append(videoCodecProfiles(capabilities), audioCodecProfile(capabilities)), "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 // 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.