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