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