This commit is contained in:
ponzischeme89
2026-08-22 15:32:51 +12:00
parent b3e3f62c54
commit 9858e44710
31 changed files with 1213 additions and 594 deletions
+32 -40
View File
@@ -1776,7 +1776,7 @@ preserve:
the search row instead of on "Off", which is the state the viewer is already in. Where
no provider can be asked it says that instead, once, rather than leaving somebody
looking for an option that is not there. `SubtitleMenuScreenshotTest` covers both.
- **Client-side it is a second screen, not a third section.** The drop-up is 344dp by about
- **Client-side it is a second screen, not a third section.** The drop-up is 440dp by about
a third of a 720p screen; stacking a track list, the size chips and search results
squeezed the tracks to one visible row. `SubtitleDownloadState.expanded` swaps
`player_subtitle_main_section` out, and Back steps out of the download half before it
@@ -1791,12 +1791,13 @@ preserve:
**Changing it mid-film is a drop-up**, `player_subtitle_overlay.xml`, anchored over the
subtitle button it opens from rather than the full-height panel it used to be. It sits on
somebody's film, so there is no scrim and the panel is near-black: the option under focus is
the only fill on it (accent green), the choice in force is a quiet grey plate with a green
label, and every other row has no background at all. The margins are measured off the
somebody's film, so there is no scrim and the panel is near-black. It uses the shared
`TvSettingsComponents` rows: focus is a raised control with a white ring, the choice in force
keeps an accent wash and a CURRENT badge after focus moves, and every colour comes from the
active Memby palette. Every other row has no background. The margins are measured off the
transport row — 44dp end, 112dp bottom to clear its 72dp strip and the controls' 28dp
padding — and the top margin is what caps the track list before its `ScrollView` (which
takes the overflow via `layout_weight`) starts scrolling instead of growing up the screen.
padding — and the top margin is what caps the track list before its `ScrollView` starts
scrolling instead of growing up the screen.
`bindSubtitleMenu` in `ui/player/SubtitleMenu.kt` fills it from plain
`SubtitleMenuEntry` lists so `SubtitleMenuScreenshotTest` can render the real menu with no
player, server or decoder → `build/screenshots/subtitles-menu/`; deciding *what* the entries
@@ -2104,42 +2105,33 @@ failure fallback; and the preroll player is stopped and parked at hand-off so it
hardware decoder while HEVC content plays. Returning to Home prepares the same instance again
in the next idle window rather than constructing one per title.
**The same clip plays behind the cold-start screen** (`ui/LaunchPreroll.kt`). It is the one
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
there. It borrows the *same* cached instance — no second decoder, no second copy of the
file — and hands it back on dispose, so the next playback still opens on a prepared player.
**It now gates the launcher**, which is the whole point of it: it was decoration, uncovered
whenever the app happened to be ready, and on a warm start that was a fraction of a second —
so the one thing Memby owns was in practice never seen. The clip plays once from the
beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home
screen composed. Things to preserve: **it gates but it can never trap** — no player, a
decoder error, or no rendered frame within three seconds all report finished immediately,
and `LAUNCH_INTRO_MAX_MS` (9s, the clip's four plus the hold plus room for a weak box) is
`AppRoot`'s outer bound on top of that, because a viewer must never be held on a black
screen by a branding clip; **`LaunchIntro.played` is process-scoped, not saved**, so an
activity Android recreates behind somebody is not a second launch and a profile switch does
not replay it; the pulsing mark and the welcome line fade *out* under the clip and back in
if the app is still opening when it ends, since the waiting screen and a four-second sting
said two things at once; and it no longer loops — looping existed so a slow cold start never
froze on the last frame, which the hold and the fade-out do instead, and a clip that never
ends cannot gate anything. It is still muted, because it now runs on every single app open,
where the pre-roll before a programme is audible and must *end*`PrerollPreloader.acquire`
/`recycle` normalise volume and repeat mode so a borrower cannot leave the next one wedged.
It is acquired **after** `withFrameNanos`, because a cold start has nothing cached and
constructing an ExoPlayer inside the first composition of the screen that must appear
immediately is the cost the idle-handler prepare exists to avoid. And
`AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still
opening" were three, and Compose identifies a composable by where it is called from, so
moving between them disposed the screen and rebuilt it — which now means returning and
re-borrowing the player twice during the busiest stretch of a launch.
**Cold start is a local poster snapshot, never a live grid.**
`data/StartupPosterSnapshotCache.kt` takes a balanced sample of films and series from the
active viewer's Continue Watching, favourites, server-composed recommendation rows and
latest films *after* Home's refresh has settled. On `Dispatchers.IO` it asks Coil for bounded
220×330 poster requests, composes them into one 1280×720 angled wall and atomically writes a
small JPEG under `filesDir/startup_posters/`, keyed by server, Emby user and Memby viewer.
The next cold start performs only a file existence check and one asynchronous local decode;
no URL is ever handed to `StartupPosterBackdrop`, and no list of poster bitmaps exists in
composition. Snapshots refresh at most daily and the directory retains eight profiles.
**What it says while it is opening is `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone
plus eight headlines, because this is the most-read copy in the app and five per tone meant
a household saw the same sentence roughly every fifth time they switched the set on.
The bundled `drawable-nodpi/startup_poster_fallback.png` is a rights-safe wall of fictional
artwork. `Theme.Memby.Launcher` draws it as MainActivity's window background before Compose
exists, while `MembyLoadingScreen` uses the same resource as Coil's placeholder and gently
crossfades a personalised file over it. One ten-second value supplies a tiny pan and scale
inside `graphicsLayer`, then holds its final frame; frame-rate reads invalidate the layer
rather than recomposing the wall. It has no focus targets and no launch hold: `AppRoot`
calls it from one opening call site and uncovers the next screen the instant update, session
and onboarding state allow it. The local video pre-roll remains playback-only; startup
never borrows its
decoder and never holds the launcher for branding.
**Welcome copy lives in `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone, because five
per tone meant a household saw the same sentence roughly every fifth time it appeared.
`WelcomeQuotesTest` counts the distinct lines a pool yields: a pool that shrank back, or
gained a duplicate on a copy-paste, looks identical to one that did not. The headline is
kept apart from the quotes and is not keyed on the tone — it names what the app is *doing*,
and rerolling it when the settings flow arrives would change the line mid-launch.
gained a duplicate on a copy-paste, looks identical to one that did not. The greeting is
assembled separately so the authenticated name is trimmed once; the player also draws from
the same tone-specific pool while it prepares a programme.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.01"
val defaultVersionName = "0.3.02"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
+1
View File
@@ -83,6 +83,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Launcher"
tools:ignore="DiscouragedApi"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter>
@@ -0,0 +1,207 @@
package com.ponzischeme89.memby.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Scale
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/** One small poster request used to build the next cold start's pre-rendered collage. */
data class StartupPosterSource(
val id: String,
val url: String,
)
/**
* Persistent, per-viewer startup artwork.
*
* The cold path only calls [snapshotFile], which performs no decode and no network work.
* [refresh] runs after the launcher is interactive and does the expensive work once: it
* fetches bounded poster thumbnails, composes them into a single television-sized bitmap,
* and atomically replaces the old snapshot. Startup therefore decodes one local image
* instead of twenty cards and can never leak one viewer's wall into another's profile.
*/
object StartupPosterSnapshotCache {
private val refreshMutex = Mutex()
fun profileIdentity(settings: Settings?): String? {
val loaded = settings?.takeIf { it.isSignedIn } ?: return null
return listOf(
loaded.serverUrl.orEmpty().trimEnd('/'),
loaded.userId.orEmpty(),
loaded.activeViewerId,
).joinToString("|")
}
/** The already-rendered local snapshot, or null. Never creates files or starts work. */
fun snapshotFile(context: Context, profileIdentity: String?): File? {
val identity = profileIdentity?.takeIf(String::isNotBlank) ?: return null
return File(snapshotDirectory(context), "${identity.sha256Prefix()}.jpg")
.takeIf { it.isFile && it.length() > 0L }
}
suspend fun refresh(
context: Context,
profileIdentity: String,
sources: List<StartupPosterSource>,
) = withContext(Dispatchers.IO) {
if (sources.size < MINIMUM_POSTERS) return@withContext
refreshMutex.withLock {
val directory = snapshotDirectory(context).apply { mkdirs() }
val target = File(directory, "${profileIdentity.sha256Prefix()}.jpg")
if (target.isFile &&
System.currentTimeMillis() - target.lastModified() < REFRESH_INTERVAL_MS
) {
return@withLock
}
val selectedSources = sources
.distinctBy(StartupPosterSource::id)
.take(MAXIMUM_POSTERS)
val posters = ArrayList<Bitmap>(selectedSources.size)
for (source in selectedSources) {
loadPoster(context, source.url)?.let(posters::add)
}
if (posters.size < MINIMUM_POSTERS) {
posters.forEach(Bitmap::recycle)
return@withLock
}
val collage = renderCollage(posters)
posters.forEach(Bitmap::recycle)
val temporary = File(directory, "${target.name}.new")
val written = runCatching {
FileOutputStream(temporary).use { output ->
check(collage.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output))
output.fd.sync()
}
}.isSuccess
collage.recycle()
if (!written) {
temporary.delete()
return@withLock
}
if (!temporary.renameTo(target)) {
temporary.copyTo(target, overwrite = true)
temporary.delete()
}
pruneOldSnapshots(directory, keep = target)
}
}
private suspend fun loadPoster(context: Context, url: String): Bitmap? {
val request = ImageRequest.Builder(context)
.data(url)
.size(POSTER_REQUEST_WIDTH, POSTER_REQUEST_HEIGHT)
.scale(Scale.FILL)
.allowHardware(false)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.build()
val result = try {
context.imageLoader.execute(request)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
return null
} as? SuccessResult ?: return null
return runCatching { result.drawable.toBitmap() }.getOrNull()
}
private fun renderCollage(posters: List<Bitmap>): Bitmap {
val output = Bitmap.createBitmap(SNAPSHOT_WIDTH, SNAPSHOT_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
canvas.drawColor(Color.rgb(7, 10, 12))
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
val destination = RectF()
val clip = Path()
canvas.save()
canvas.rotate(WALL_ANGLE_DEGREES, SNAPSHOT_WIDTH / 2f, SNAPSHOT_HEIGHT / 2f)
var posterIndex = 0
repeat(POSTER_ROWS) { row ->
val rowOffset = if (row % 2 == 0) -POSTER_WIDTH / 2f else -POSTER_WIDTH * 0.05f
var x = -POSTER_WIDTH * 1.5f + rowOffset
val y = -POSTER_HEIGHT * 0.72f + row * (POSTER_HEIGHT + POSTER_GAP)
while (x < SNAPSHOT_WIDTH + POSTER_WIDTH * 1.5f) {
destination.set(x, y, x + POSTER_WIDTH, y + POSTER_HEIGHT)
clip.reset()
clip.addRoundRect(destination, POSTER_CORNER, POSTER_CORNER, Path.Direction.CW)
canvas.save()
canvas.clipPath(clip)
drawCentreCrop(canvas, posters[posterIndex % posters.size], destination, paint)
canvas.restore()
posterIndex += 1
x += POSTER_WIDTH + POSTER_GAP
}
}
canvas.restore()
return output
}
private fun drawCentreCrop(canvas: Canvas, bitmap: Bitmap, destination: RectF, paint: Paint) {
val targetRatio = destination.width() / destination.height()
val sourceRatio = bitmap.width.toFloat() / bitmap.height.coerceAtLeast(1)
val source = if (sourceRatio > targetRatio) {
val width = (bitmap.height * targetRatio).toInt().coerceAtLeast(1)
val left = (bitmap.width - width) / 2
Rect(left, 0, left + width, bitmap.height)
} else {
val height = (bitmap.width / targetRatio).toInt().coerceAtLeast(1)
val top = (bitmap.height - height) / 2
Rect(0, top, bitmap.width, top + height)
}
canvas.drawBitmap(bitmap, source, destination, paint)
}
private fun pruneOldSnapshots(directory: File, keep: File) {
directory.listFiles { file -> file.extension.equals("jpg", ignoreCase = true) }
.orEmpty()
.filterNot { it == keep }
.sortedByDescending(File::lastModified)
.drop(MAXIMUM_SNAPSHOTS - 1)
.forEach(File::delete)
}
private fun snapshotDirectory(context: Context): File =
File(context.filesDir, SNAPSHOT_DIRECTORY)
private fun String.sha256Prefix(): String = MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.take(12)
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
private const val SNAPSHOT_DIRECTORY = "startup_posters"
private const val SNAPSHOT_WIDTH = 1280
private const val SNAPSHOT_HEIGHT = 720
private const val POSTER_REQUEST_WIDTH = 220
private const val POSTER_REQUEST_HEIGHT = 330
private const val POSTER_WIDTH = 184f
private const val POSTER_HEIGHT = 276f
private const val POSTER_GAP = 16f
private const val POSTER_CORNER = 8f
private const val POSTER_ROWS = 4
private const val WALL_ANGLE_DEGREES = -10f
private const val MINIMUM_POSTERS = 6
private const val MAXIMUM_POSTERS = 20
private const val MAXIMUM_SNAPSHOTS = 8
private const val JPEG_QUALITY = 84
private const val REFRESH_INTERVAL_MS = 24L * 60L * 60L * 1_000L
}
@@ -312,7 +312,7 @@ class RemoteConfigManager(context: Context) {
.callTimeout(3, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
runCatching {
return runCatching {
client.newCall(request).execute().use { response ->
if (response.code == 304) return cachedEtag
if (!response.isSuccessful) return cachedEtag
@@ -37,7 +37,6 @@ import com.ponzischeme89.memby.update.requiredUpdateSatisfied
import com.ponzischeme89.memby.update.ServerUpdateService
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@@ -100,19 +99,6 @@ internal fun AppRoot(
// Wakes the check loop: a refusal arriving, or the viewer pressing Try again on the
// retired-build screen. Conflated because the only thing either says is "ask now".
val updateWake = remember { Channel<Unit>(Channel.CONFLATED) }
// 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.
delay(LAUNCH_INTRO_MAX_MS.milliseconds)
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
@@ -322,50 +308,35 @@ internal fun AppRoot(
// the server will refuse.
val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
?: reportedRequiredUpdate
// The three states below that mean "still opening" used to be three separate calls
// to MembyLoadingScreen, and Compose identifies a composable by where it is called
// from — so passing between them disposed the screen and built it again. That was
// only a wasted animation before; now it hands the pre-roll player back and borrows
// 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 {
// One call site survives every state that still means "opening", so the local
// poster snapshot never reloads while update/session/onboarding checks settle.
// There is deliberately no decorative hold: the instant the next screen is ready,
// it replaces this one.
val opening = when {
// A required update is a service gate, not launcher content. Once the gateway
// answers, uncover it immediately instead of making the viewer finish the
// decorative opening clip first. Optional prompts may wait for the intro.
update?.isMandatory == true -> null
// 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()
update != null -> null
// opening artwork first. Optional prompts can replace it as soon as ready.
update?.isMandatory == true -> false
!initialUpdateCheckComplete -> true
update != null -> false
// Retired, verdict not here yet. The retired-build screen takes the frame
// rather than the opening screen: it says what has happened and offers the one
// thing there is to do about it, where a loading screen with nothing behind it
// reads as a television that has stopped working.
retiredVersion != null -> null
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
retiredVersion != null -> false
loaded == null -> true
// Ordered exactly as the screens below are: adding a profile outranks waiting
// on the onboarding answer, and swapping the two would put the loading screen
// over a sign-in form somebody is halfway through.
addingProfile -> null
loaded.isSignedIn && onboarding == null ->
loaded.welcomeQuoteStyle.orEmpty()
else -> null
addingProfile -> false
loaded.isSignedIn && onboarding == null -> true
else -> false
}
if (openingQuoteStyle != null) {
MembyLoadingScreen(
quoteStyle = openingQuoteStyle,
onIntroFinished = {
LaunchIntro.played = true
introHolding = false
},
)
if (opening) {
MembyLoadingScreen(settings = loaded)
}
when {
openingQuoteStyle != null -> Unit
opening -> Unit
update != null -> UpdateScreen(
update = update,
onDismiss = {
@@ -288,6 +288,7 @@ internal fun FocusedHomeMetadata(
homeViewModel: HomeViewModel,
metadataHeroContentOrder: List<String>,
metadataHeroTimeRemainingColour: String,
isContinueWatchingItem: Boolean,
modifier: Modifier = Modifier,
) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
@@ -300,6 +301,7 @@ internal fun FocusedHomeMetadata(
loading = homeContent.loading.isNotEmpty(),
contentOrder = metadataHeroContentOrder,
timeRemainingColour = metadataHeroTimeRemainingColour,
isContinueWatchingItem = isContinueWatchingItem,
modifier = modifier,
)
}
@@ -68,6 +68,8 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.StartupPosterSnapshotCache
import com.ponzischeme89.memby.data.StartupPosterSource
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
@@ -222,6 +224,41 @@ internal fun HomeScreen(
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
val startupPosterIdentity = remember(
settings.serverUrl,
settings.userId,
settings.activeViewerId,
) { StartupPosterSnapshotCache.profileIdentity(settings) }
val startupPosters = remember(homeContent) { startupPosterItems(homeContent) }
val startupPosterSources = remember(startupPosters) {
startupPosters.mapNotNull { item ->
repo.primaryUrl(item, maxWidth = 220)?.let { url ->
StartupPosterSource(item.id, url)
}
}
}
val startupPosterSignature = remember(startupPosterSources) {
startupPosterSources.joinToString("|") { it.id }
}
LaunchedEffect(
startupPosterIdentity,
homeContent.loading.isEmpty(),
startupPosterSignature,
) {
val identity = startupPosterIdentity ?: return@LaunchedEffect
if (homeContent.loading.isNotEmpty() || startupPosterSources.size < 6) {
return@LaunchedEffect
}
// The launcher has already submitted its cached rows and the fresh home request
// has settled. Give focus and metadata work first use of the device, then prepare
// one small local bitmap for the *next* process start.
delay(3_000)
StartupPosterSnapshotCache.refresh(
context = context.applicationContext,
profileIdentity = identity,
sources = startupPosterSources,
)
}
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val metadataHeroContentOrder by
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
@@ -1384,6 +1421,9 @@ internal fun HomeScreen(
homeViewModel = homeViewModel,
metadataHeroContentOrder = metadataHeroContentOrder,
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
isContinueWatchingItem = rows.firstOrNull {
it.id == focusedHomeRowId
}?.kind == MediaRowKind.CONTINUE,
modifier = Modifier
.height(metadataHeight)
// LazyColumn is drawn later as a sibling. Keep the hero
@@ -1,182 +0,0 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.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
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
/**
* The longest the launcher may ever be held back by the opening clip.
*
* 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 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 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.
*/
@androidx.annotation.OptIn(markerClass = [UnstableApi::class])
@Composable
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.
var player by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
val acquired = runCatching { PrerollPreloader.acquire(context) }.getOrNull()
if (acquired == null) currentOnFinished() else player = acquired
}
// 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() {
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,
// 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 -> if (!completed) active.playWhenReady = true
else -> Unit
}
}
active.addListener(listener)
lifecycleOwner.lifecycle.addObserver(observer)
active.volume = 0f
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)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
active.removeListener(listener)
PrerollPreloader.recycle(active)
}
}
// 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 ->
PlayerView(viewContext).apply {
useController = false
// Fill rather than fit: this sits behind text as a wash, and letterbox bars
// on a screen whose whole job is to look like Memby would read as a fault.
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
this.player = active
}
},
onRelease = { it.player = null },
)
}
@@ -1,5 +1,8 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -7,12 +10,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -36,6 +41,7 @@ internal fun MetadataHero(
loading: Boolean,
contentOrder: List<String> = emptyList(),
timeRemainingColour: String = "green",
isContinueWatchingItem: Boolean = false,
modifier: Modifier = Modifier,
) {
Box(
@@ -73,6 +79,7 @@ internal fun MetadataHero(
loading = loading,
contentOrder = contentOrder,
timeRemainingColour = timeRemainingColour,
isContinueWatchingItem = isContinueWatchingItem,
modifier = Modifier
.fillMaxSize()
.padding(
@@ -112,6 +119,16 @@ private fun MetadataHeroArtwork(
}
}
if (request == null) return
val artworkScale = remember(artwork) { Animatable(MetadataHeroArtworkInitialScale) }
LaunchedEffect(artwork) {
artworkScale.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = MetadataHeroArtworkZoomOutMs,
easing = LinearOutSlowInEasing,
),
)
}
Box(modifier) {
Box(
modifier = Modifier
@@ -124,7 +141,12 @@ private fun MetadataHeroArtwork(
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = artworkScale.value
scaleY = artworkScale.value
},
)
Box(
Modifier.fillMaxSize().background(
@@ -151,4 +173,6 @@ private fun MetadataHeroArtwork(
}
private const val MetadataHeroArtworkCrossfadeMs = 180
private const val MetadataHeroArtworkInitialScale = 1.05f
private const val MetadataHeroArtworkZoomOutMs = 10_000
private const val MetadataHeroArtworkWidthFraction = 0.68f
@@ -99,147 +99,9 @@ import kotlin.time.Duration.Companion.milliseconds
@Composable
internal fun MembyLoadingScreen(
quoteStyle: String? = null,
onIntroFinished: () -> Unit = {},
settings: Settings? = null,
) {
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 && !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
delay(460.milliseconds)
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.
val headline = remember { randomLoadingHeadline() }
val transition = rememberInfiniteTransition(label = "cold-start")
val pulse by transition.animateFloat(
initialValue = 0.96f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-logo-pulse",
)
val glow by transition.animateFloat(
initialValue = 0.35f,
targetValue = 0.78f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-glow",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(MembySplashTint, MembySurface),
radius = 1_100f,
),
),
contentAlignment = Alignment.Center,
) {
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 the whole column gives way while it runs.
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier
.width(82.dp)
.height(68.dp)
.graphicsLayer {
scaleX = pulse
scaleY = pulse
alpha = 0.82f + (glow * 0.18f)
},
)
Text(
headline,
color = Color.White.copy(alpha = 0.88f),
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
)
Text(
welcomeQuote,
color = Color.White.copy(alpha = 0.58f),
fontSize = 13.sp,
maxLines = 2,
textAlign = TextAlign.Center,
modifier = Modifier.width(440.dp),
)
Box(
Modifier
.width(92.dp)
.height(2.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.10f)),
) {
// Drawn, not measured: `fillMaxWidth(glow)` would recompose this screen
// every frame — during cold start, which is the one moment the device has
// nothing to spare.
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.drawBehind {
drawRoundRect(
color = MembyAccent,
size = Size(size.width * glow, size.height),
cornerRadius = CornerRadius(size.height / 2f),
)
},
)
}
}
}
StartupPosterBackdrop(settings = settings)
}
@Composable
@@ -179,6 +179,7 @@ private fun ratingIcon(source: String): Int? = when (source.lowercase()) {
"imdb" -> R.drawable.ic_rating_imdb
"letterboxd" -> R.drawable.ic_rating_letterboxd
"tmdb" -> R.drawable.ic_rating_tmdb
"trakt" -> R.drawable.ic_rating_trakt
else -> null
}
@@ -101,10 +101,14 @@ internal fun BaseItem.toResumableMediaCardModel(
// This adapter is used by the Continue Watching card. Within that merged row, an
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
// positive playhead and films are never supplied by Emby's Next Up feed.
isNextUp = isEpisode && !played && (playbackPosition ?: 0L) <= 0L,
isNextUp = isNextUpInContinueWatching(),
)
}
/** The Next Up half of Memby's merged Continue Watching row. */
internal fun BaseItem.isNextUpInContinueWatching(): Boolean =
isEpisode && userData?.played != true && (userData?.playbackPositionTicks ?: 0L) <= 0L
internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
val cleanName = name?.trim().orEmpty()
val code = if (season != null && season >= 0 && episode != null && episode >= 0) {
@@ -0,0 +1,154 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.StartupPosterSnapshotCache
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* The non-interactive opening canvas. Its model is always a bundled resource or a local
* file; passing a URL here would put artwork I/O back on the launcher's critical path.
*
* One bounded value drives the whole drift and is read only inside [graphicsLayer],
* so the animation invalidates the layer rather than recomposing the poster wall at frame
* rate. It stops after ten seconds and holds its final frame; the collage itself is one
* pre-rendered bitmap, not a live grid of image requests.
*/
@Composable
internal fun StartupPosterBackdrop(
settings: Settings?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val profileIdentity = StartupPosterSnapshotCache.profileIdentity(settings)
val snapshot = remember(profileIdentity) {
StartupPosterSnapshotCache.snapshotFile(context, profileIdentity)
}
val request = remember(snapshot) {
ImageRequest.Builder(context)
.data(snapshot ?: R.drawable.startup_poster_fallback)
.size(1280, 720)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.crossfade(if (snapshot == null) 0 else 320)
.build()
}
val fallback = painterResource(R.drawable.startup_poster_fallback)
val drift = remember { Animatable(0f) }
LaunchedEffect(drift) {
drift.animateTo(
targetValue = 1f,
animationSpec = tween(10_000, easing = FastOutSlowInEasing),
)
}
Box(modifier.fillMaxSize().background(MembySurface)) {
AsyncImage(
model = request,
contentDescription = null,
placeholder = fallback,
error = fallback,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val progress = drift.value
val scale = 1.045f + progress * 0.025f
scaleX = scale
scaleY = scale
translationX = -14f + progress * 28f
translationY = 8f - progress * 16f
},
)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(
Brush.radialGradient(
colors = listOf(
Color.Black.copy(alpha = 0.38f),
Color.Black.copy(alpha = 0.72f),
),
radius = size.maxDimension * 0.78f,
),
)
drawRect(
Brush.verticalGradient(
colors = listOf(
MembySurface.copy(alpha = 0.18f),
MembySplashTint.copy(alpha = 0.48f),
MembySurface.copy(alpha = 0.76f),
),
),
)
},
)
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier
.align(Alignment.Center)
.width(88.dp)
.height(72.dp),
)
}
}
/**
* A balanced, deterministic sample from the viewer's home snapshot. Round-robin selection
* stops a long recommendation row crowding out Continue Watching or Favourites while the
* final distinct pass keeps one title from appearing through several server rows.
*/
internal fun startupPosterItems(state: HomeUiState, limit: Int = 20): List<BaseItem> {
fun List<BaseItem>.posterEligible() = filter { item ->
item.id.isNotBlank() &&
(item.isMovie || item.isSeries) &&
item.imageTags.keys.any { it.equals("Primary", ignoreCase = true) }
}
val pools = listOf(
state.continueWatching.posterEligible(),
state.favorites.posterEligible(),
state.rows.flatMap { it.items }.posterEligible(),
state.latestMovies.posterEligible(),
)
val selected = ArrayList<BaseItem>(limit)
val seen = HashSet<String>()
var index = 0
while (selected.size < limit && pools.any { index < it.size }) {
pools.forEach { pool ->
val item = pool.getOrNull(index) ?: return@forEach
if (seen.add(item.id)) selected += item
}
index += 1
}
return selected
}
@@ -18,19 +18,16 @@ internal enum class WelcomeQuoteStyle(
}
/**
* What Memby says while it is opening.
* What Memby says after sign-in and while a video prepares.
*
* These are read far more often than anything else the app writes the loading screen is
* the one thing every launch shows, and the pre-roll shows one too. Five lines per tone
* meant a household saw the same sentence roughly every fifth time they switched the
* television on, which is how a line written to be a small pleasure becomes wallpaper. The
* pools are deliberately deep enough that a repeat inside a week is unlikely rather than
* Five lines per tone meant a household saw the same sentence roughly every fifth time it
* appeared, which is how a line written to be a small pleasure becomes wallpaper. The pools
* are deliberately deep enough that a repeat inside a week is unlikely rather than
* guaranteed.
*
* Every line has to survive being read for the hundredth time, so they lean on observation
* rather than on a joke with a punchline. Two lengths are avoided on purpose: nothing so
* short it reads as a status message, and nothing so long it wraps past the two lines the
* loading screen gives it at 13sp across 440dp.
* short it reads as a status message, and nothing so long it overwhelms the surface using it.
*/
private val WelcomeQuotes = mapOf(
WelcomeQuoteStyle.NEUTRAL to listOf(
@@ -116,24 +113,6 @@ private val WelcomeQuotes = mapOf(
),
)
/**
* The line above the quote on the cold-start screen.
*
* Kept apart from the quotes rather than folded into them: this one names what the app is
* *doing*, so it has to stay a plain progress statement in every tone, and it is repeated on
* a screen where the ellipsis is the only thing promising the wait ends.
*/
private val LoadingHeadlines = listOf(
"Opening Memby…",
"Waking Memby up…",
"Getting things ready…",
"Fetching your library…",
"Setting the scene…",
"Tuning in…",
"Nearly there…",
"Rolling the shelves out…",
)
internal fun randomWelcomeQuote(
styleValue: String?,
random: Random = Random.Default,
@@ -142,9 +121,6 @@ internal fun randomWelcomeQuote(
return quotes[random.nextInt(quotes.size)]
}
internal fun randomLoadingHeadline(random: Random = Random.Default): String =
LoadingHeadlines[random.nextInt(LoadingHeadlines.size)]
internal fun loginWelcomeMessage(
username: String,
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
@@ -0,0 +1,387 @@
package com.ponzischeme89.memby.ui.components
import android.app.Dialog
import android.content.Context
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.compose.ui.graphics.toArgb
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/**
* One answer in a television settings panel. The panel owns its invariant chrome while
* callers supply the copy, current value and action, so player settings and future compact
* pickers do not grow their own focus language.
*/
data class TvSettingsMenuOption(
val label: String,
val supportingText: String = "",
val value: String = "",
val selected: Boolean = false,
val enabled: Boolean = true,
val prominent: Boolean = false,
val onClick: () -> Unit,
)
/** A Memby settings panel designed for a remote rather than touch-sized platform dialogs. */
fun showTvSettingsMenu(
context: Context,
title: String,
description: String = "",
options: List<TvSettingsMenuOption>,
onDismiss: () -> Unit = {},
): Dialog {
var optionChosen = false
val dialog = tvSettingsDialog(context) { if (!optionChosen) onDismiss() }
val content = tvSettingsPanel(context, title, description)
val list = content.options
options.forEach { option ->
list.addView(
tvSettingsOptionView(
context = context,
label = option.label,
supportingText = option.supportingText,
value = option.value,
selected = option.selected,
enabled = option.enabled,
prominent = option.prominent,
) {
optionChosen = true
dialog.dismiss()
option.onClick()
},
)
}
finishTvSettingsDialog(
dialog = dialog,
root = content.root,
options = list,
optionCount = options.size,
initialFocusIndex = options.indexOfFirst(TvSettingsMenuOption::selected).coerceAtLeast(0),
)
return dialog
}
/** A multi-choice variant with explicit Save and Cancel actions. */
fun showTvSettingsMultiChoiceMenu(
context: Context,
title: String,
description: String,
labels: List<String>,
initiallySelected: Set<Int>,
onSave: (Set<Int>) -> Unit,
onDismiss: () -> Unit = {},
): Dialog {
var saved = false
val dialog = tvSettingsDialog(context) { if (!saved) onDismiss() }
val selected = initiallySelected.toMutableSet()
val content = tvSettingsPanel(context, title, description)
labels.forEachIndexed { index, label ->
lateinit var row: View
fun bind() {
row = tvSettingsOptionView(
context = context,
label = label,
selected = index in selected,
) {
if (!selected.add(index)) selected.remove(index)
val position = content.options.indexOfChild(row)
content.options.removeViewAt(position)
bind()
content.options.addView(row, position)
row.requestFocus()
}
}
bind()
content.options.addView(row)
}
content.root.addView(
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
addView(tvSettingsAction(context, "Cancel", primary = false) { dialog.dismiss() })
addView(tvSettingsAction(context, "Save", primary = true) {
saved = true
dialog.dismiss()
onSave(selected.toSet())
})
},
LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(18)
},
)
finishTvSettingsDialog(
dialog = dialog,
root = content.root,
options = content.options,
optionCount = labels.size,
initialFocusIndex = selected.minOrNull() ?: 0,
)
return dialog
}
/**
* Shared option row used by the modal panels and the subtitle drop-up. Focus and selection
* remain separate: focus is a white ring on a raised control, while the saved/current
* answer keeps an accent wash and a labelled badge when the remote moves away.
*/
fun tvSettingsOptionView(
context: Context,
label: String,
supportingText: String = "",
value: String = "",
selected: Boolean = false,
enabled: Boolean = true,
prominent: Boolean = false,
compact: Boolean = false,
onClick: () -> Unit,
): View {
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
isFocusable = enabled
isClickable = enabled
alpha = if (enabled) 1f else 0.45f
minimumHeight = context.dp(if (compact) 42 else 58)
setPadding(context.dp(if (compact) 12 else 16), context.dp(8), context.dp(if (compact) 12 else 16), context.dp(8))
}
val copy = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
}
val title = TextView(context).apply {
text = label
textSize = if (compact) 14f else 16f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1
}
copy.addView(title)
val detail = supportingText.takeIf(String::isNotBlank)?.let { text ->
TextView(context).apply {
this.text = text
textSize = 12f
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
maxLines = 2
copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(2)
})
}
}
row.addView(copy, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
val trailing = TextView(context).apply {
text = if (selected) "CURRENT" else value
textSize = if (selected) 10f else 12f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
gravity = Gravity.CENTER
letterSpacing = if (selected) 0.08f else 0f
visibility = if (text.isBlank()) View.GONE else View.VISIBLE
setPadding(context.dp(10), context.dp(5), context.dp(10), context.dp(5))
}
row.addView(trailing, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginStart = context.dp(14)
})
fun render(focused: Boolean) {
val fill = when {
focused -> MembyControlSurface.toArgb()
prominent -> MembyAccentMuted.toArgb()
selected -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT
}
val stroke = when {
focused -> android.graphics.Color.WHITE
prominent || selected -> MembyAccent.toArgb()
else -> MembyHairline.toArgb()
}
row.background = roundedBackground(context, fill, stroke, if (focused) 2 else 1, 10)
title.setTextColor(if (focused) android.graphics.Color.WHITE else MembyOnSurface.toArgb())
detail?.setTextColor(if (focused) MembyMutedText.toArgb() else MembyQuietText.toArgb())
trailing.setTextColor(if (focused) MembyAccentInk.toArgb() else MembyAccentBright.toArgb())
trailing.background = roundedBackground(
context,
if (focused) MembyAccent.toArgb() else MembyAccentMuted.toArgb(),
android.graphics.Color.TRANSPARENT,
0,
999,
)
}
render(false)
row.setOnFocusChangeListener { _, focused -> render(focused) }
row.setOnClickListener { if (enabled) onClick() }
row.layoutParams = LinearLayout.LayoutParams(
if (compact) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
if (compact) marginEnd = context.dp(6) else bottomMargin = context.dp(6)
}
return row
}
/** Applies the same raised, theme-driven panel surface to an embedded settings pop-up. */
fun applyTvSettingsPanelSurface(view: View) {
val context = view.context
view.background = roundedBackground(
context,
MembySurfaceRaised.toArgb(),
MembyOutline.toArgb(),
1,
MembyPanelCorner.value.toInt(),
)
view.elevation = context.dp(18).toFloat()
}
private data class TvSettingsPanel(
val root: LinearLayout,
val options: LinearLayout,
)
private fun tvSettingsPanel(context: Context, title: String, description: String): TvSettingsPanel {
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(context.dp(28), context.dp(25), context.dp(28), context.dp(25))
background = roundedBackground(
context,
MembySurfaceRaised.toArgb(),
MembyOutline.toArgb(),
1,
MembyPanelCorner.value.toInt(),
)
elevation = context.dp(24).toFloat()
}
root.addView(TextView(context).apply {
text = title
setTextColor(MembyOnSurface.toArgb())
textSize = 24f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
})
if (description.isNotBlank()) {
root.addView(TextView(context).apply {
text = description
setTextColor(MembyMutedText.toArgb())
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(5)
})
}
val options = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
val scroll = ScrollView(context).apply {
isFillViewport = false
overScrollMode = View.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false
addView(options)
}
root.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(20)
})
return TvSettingsPanel(root, options)
}
private fun tvSettingsDialog(context: Context, onDismiss: () -> Unit): Dialog =
Dialog(context).apply {
requestWindowFeature(Window.FEATURE_NO_TITLE)
window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.72f }
setGravity(Gravity.CENTER)
}
setOnDismissListener { onDismiss() }
}
private fun finishTvSettingsDialog(
dialog: Dialog,
root: LinearLayout,
options: LinearLayout,
optionCount: Int,
initialFocusIndex: Int,
) {
val context = root.context
val frame = FrameLayout(context).apply {
setPadding(context.dp(8), context.dp(8), context.dp(8), context.dp(8))
addView(root, FrameLayout.LayoutParams(context.dp(560), ViewGroup.LayoutParams.WRAP_CONTENT))
}
dialog.setContentView(frame)
val scroll = options.parent as ScrollView
scroll.layoutParams = (scroll.layoutParams as LinearLayout.LayoutParams).apply {
height = context.dp((optionCount.coerceIn(1, 6) * 76).coerceAtMost(390))
}
dialog.show()
dialog.window?.setLayout(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
options.getChildAt(initialFocusIndex)?.requestFocus()
}
private fun tvSettingsAction(context: Context, label: String, primary: Boolean, onClick: () -> Unit): View {
val button = TextView(context).apply {
text = label
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
gravity = Gravity.CENTER
isFocusable = true
isClickable = true
setPadding(context.dp(22), context.dp(11), context.dp(22), context.dp(11))
setOnClickListener { onClick() }
}
fun render(focused: Boolean) {
button.setTextColor(
when {
focused && primary -> MembyAccentInk.toArgb()
else -> MembyOnSurface.toArgb()
},
)
button.background = roundedBackground(
context,
when {
focused && primary -> MembyAccent.toArgb()
focused -> MembyControlSurface.toArgb()
primary -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT
},
if (focused) android.graphics.Color.WHITE else MembyOutline.toArgb(),
if (focused) 2 else 1,
10,
)
}
render(false)
button.setOnFocusChangeListener { _, focused -> render(focused) }
button.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginStart = context.dp(10)
}
return button
}
private fun roundedBackground(
context: Context,
fill: Int,
stroke: Int,
strokeWidthDp: Int,
cornerDp: Int,
) = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(fill)
cornerRadius = context.dp(cornerDp).toFloat()
if (strokeWidthDp > 0) setStroke(context.dp(strokeWidthDp), stroke)
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
@@ -133,6 +133,7 @@ fun MediaMetadataPanel(
loading: Boolean,
contentOrder: List<String>,
timeRemainingColour: String,
isContinueWatchingItem: Boolean,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier) {
@@ -162,6 +163,7 @@ fun MediaMetadataPanel(
compactLayout,
contentOrder,
timeRemainingColour,
isContinueWatchingItem,
)
}
}
@@ -207,6 +209,7 @@ private fun MetadataContent(
compact: Boolean,
contentOrder: List<String>,
timeRemainingColour: String,
isContinueWatchingItem: Boolean,
) {
if (item.isSchedule) {
ScheduleMetadataContent(item, contentWidth, compact)
@@ -277,7 +280,11 @@ private fun MetadataContent(
Modifier.fillMaxWidth(),
)
}
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(
item = item,
colour = timeRemainingColour,
isContinueWatchingItem = isContinueWatchingItem,
)
MetadataHeroSection.Summary -> Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
@@ -444,11 +451,14 @@ private fun MetadataStatus(item: BaseItem) {
}
@Composable
private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
private fun MetadataTimeRemaining(
item: BaseItem,
colour: String,
isContinueWatchingItem: Boolean,
) {
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val minutes = metadataHeroMinutesRemaining(item) ?: return
val progress = (position.toFloat() / runtime).coerceIn(0f, 1f)
val label = metadataHeroProgressLabel(item, isContinueWatchingItem) ?: return
val progress = resumableProgress(position, item.runTimeTicks)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -463,16 +473,24 @@ private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
)
}
Text(
metadataHeroTimeRemainingLabel(minutes),
label,
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
fontSize = 13.sp,
lineHeight = 18.sp,
fontSize = 12.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
internal fun metadataHeroProgressLabel(
item: BaseItem,
isContinueWatchingItem: Boolean,
): String? = when {
isContinueWatchingItem && item.isNextUpInContinueWatching() -> "Next up"
else -> metadataHeroMinutesRemaining(item)?.let(::metadataHeroTimeRemainingLabel)
}
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
@@ -1,7 +1,6 @@
package com.ponzischeme89.memby.ui.player
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.Activity
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
@@ -101,6 +100,9 @@ import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.ui.MainActivity
import com.ponzischeme89.memby.ui.EmbyOutageBanner
import com.ponzischeme89.memby.ui.ServiceAlertBanner
import com.ponzischeme89.memby.ui.components.TvSettingsMenuOption
import com.ponzischeme89.memby.ui.components.showTvSettingsMenu
import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu
import com.ponzischeme89.memby.ui.randomWelcomeQuote
import com.ponzischeme89.memby.ui.theme.MembyTheme
import kotlinx.coroutines.Job
@@ -4503,71 +4505,101 @@ class PlayerActivity : ComponentActivity() {
// Cast is deliberately absent: it has its own button in the transport row now, and
// one thing reachable two ways is one thing whose two entry points drift apart.
val passthrough = ServiceLocator.settings.current.audioPassthroughPreference
val passthroughOption = "Soundbar / passthrough · ${passthroughOsdSummary(passthrough)}"
val pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
val options = buildList {
if (audio) add("Audio")
val selectedAudio = selectedTrackLabel(C.TRACK_TYPE_AUDIO)
val selectedSubtitle = when {
subtitlePreference == false -> "Off"
encodedSubtitleId != null -> "Burned in"
else -> selectedTrackLabel(C.TRACK_TYPE_TEXT).ifBlank { "On" }
}
val options = buildList<TvSettingsMenuOption> {
if (audio) add(
TvSettingsMenuOption(
label = "Audio",
supportingText = "Choose the soundtrack for this video.",
value = selectedAudio,
onClick = { showTrackPicker(C.TRACK_TYPE_AUDIO) },
),
)
// The provider owns a trailer's captions; offering Memby's subtitle surface
// here would contradict the transport row that deliberately withholds it.
if (enhancementsAvailable) add("Subtitles & appearance")
add(passthroughOption)
add(pictureOption)
if (enhancementsAvailable) add(
TvSettingsMenuOption(
label = "Subtitles & appearance",
supportingText = "Choose a track, text size or download.",
value = selectedSubtitle,
onClick = { showSubtitleOverlay() },
),
)
add(
TvSettingsMenuOption(
label = "Soundbar / passthrough",
supportingText = "Control which surround formats Memby sends directly.",
value = passthroughOsdSummary(passthrough),
onClick = { showAudioPassthroughPicker() },
),
)
add(
TvSettingsMenuOption(
label = "Picture size",
supportingText = "Fit or crop the video to your television.",
value = selectedPictureMode().shortLabel,
onClick = { showPictureModePicker() },
),
)
}
AlertDialog.Builder(this)
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
.setItems(options.toTypedArray()) { _, which ->
when (options[which]) {
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
"Subtitles & appearance" -> showSubtitleOverlay()
passthroughOption -> showAudioPassthroughPicker()
pictureOption -> showPictureModePicker()
}
}
.show()
playerView?.hideController()
showTvSettingsMenu(
context = this,
title = "Playback options",
description = "Fine-tune this video without leaving playback.",
options = options,
onDismiss = { playerView?.showController() },
)
}
private fun showAudioPassthroughPicker() {
val current = ServiceLocator.settings.current.audioPassthroughPreference
val options = arrayOf("Auto detect", "Off", "Choose formats…")
val selected = when {
current.mode == AudioPassthroughMode.AUTO -> 0
current.codecs.isEmpty() -> 1
else -> 2
}
AlertDialog.Builder(this)
.setTitle("Soundbar / passthrough")
.setSingleChoiceItems(options, selected) { dialog, which ->
when (which) {
0 -> saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
1 -> saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
else -> {
dialog.dismiss()
showManualPassthroughPicker(current.codecs)
return@setSingleChoiceItems
}
}
dialog.dismiss()
}
.setNegativeButton("Cancel", null)
.show()
showTvSettingsMenu(
context = this,
title = "Soundbar / passthrough",
description = "Choose how Memby sends surround sound to your television or soundbar.",
options = listOf(
TvSettingsMenuOption("Auto detect", "Use the formats reported by this device.", selected = selected == 0) {
saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
},
TvSettingsMenuOption("Off", "Decode audio before sending it to the device.", selected = selected == 1) {
saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
},
TvSettingsMenuOption("Choose formats", "Select the surround formats your soundbar accepts.", selected = selected == 2) {
showManualPassthroughPicker(current.codecs)
},
),
onDismiss = { playerView?.showController() },
)
}
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
val selected = SurroundCodec.entries.map { it in initial }.toBooleanArray()
AlertDialog.Builder(this)
.setTitle("Formats accepted by your soundbar")
.setMultiChoiceItems(
SurroundCodec.entries.map(SurroundCodec::label).toTypedArray(),
selected,
) { _, which, checked -> selected[which] = checked }
.setPositiveButton("Save") { _, _ ->
showTvSettingsMultiChoiceMenu(
context = this,
title = "Accepted surround formats",
description = "Select every format your soundbar or receiver can play directly.",
labels = SurroundCodec.entries.map(SurroundCodec::label),
initiallySelected = SurroundCodec.entries.indices.filterTo(mutableSetOf()) {
SurroundCodec.entries[it] in initial
},
onSave = { selected ->
saveAudioPassthrough(
AudioPassthroughMode.MANUAL,
SurroundCodec.entries.filterIndexed { index, _ -> selected[index] }.toSet(),
SurroundCodec.entries.filterIndexed { index, _ -> index in selected }.toSet(),
)
}
.setNegativeButton("Cancel", null)
.show()
},
onDismiss = { playerView?.showController() },
)
}
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
@@ -4582,22 +4614,26 @@ class PlayerActivity : ComponentActivity() {
private fun showPictureModePicker() {
val selected = selectedPictureMode()
AlertDialog.Builder(this)
.setTitle("Picture size")
.setSingleChoiceItems(
PictureMode.entries.map(PictureMode::label).toTypedArray(),
PictureMode.entries.indexOf(selected),
) { dialog, which ->
val mode = PictureMode.entries[which]
getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(PICTURE_MODE_KEY, mode.key)
.apply()
applyPictureMode(mode)
dialog.dismiss()
playerView?.hideController()
}
.show()
showTvSettingsMenu(
context = this,
title = "Picture size",
description = "Choose how this video fits the television screen.",
options = PictureMode.entries.map { mode ->
TvSettingsMenuOption(
label = mode.label,
selected = mode == selected,
onClick = {
getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(PICTURE_MODE_KEY, mode.key)
.apply()
applyPictureMode(mode)
playerView?.hideController()
},
)
},
onDismiss = { playerView?.showController() },
)
}
/**
@@ -5281,30 +5317,67 @@ class PlayerActivity : ComponentActivity() {
current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index)
}
}.let { if (it >= 0) it else if (trackType == C.TRACK_TYPE_TEXT) 0 else -1 }
val dialog = AlertDialog.Builder(this)
.setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles")
.setSingleChoiceItems(choices.map { it.label }.toTypedArray(), selected) { dialog, which ->
val choice = choices[which]
val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType)
builder.setTrackTypeDisabled(trackType, choice.group == null)
if (choice.group != null) {
builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index)))
}
playback.trackSelectionParameters = builder.build()
if (trackType == C.TRACK_TYPE_AUDIO) {
reportProgress(playback.currentPosition, !playback.isPlaying, "AudioTrackChange")
}
if (trackType == C.TRACK_TYPE_TEXT) {
subtitlePreference = choice.group != null
rememberSubtitleChoice(
enabled = choice.group != null,
language = choice.group?.getFormat(choice.index)?.language,
showTvSettingsMenu(
context = this,
title = if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles",
description = if (trackType == C.TRACK_TYPE_AUDIO) {
"Choose the soundtrack for this video."
} else {
"Choose the subtitle track shown over this video."
},
options = choices.mapIndexed { which, choice ->
TvSettingsMenuOption(
label = choice.label,
selected = which == selected,
onClick = {
val builder = playback.trackSelectionParameters.buildUpon()
.clearOverridesOfType(trackType)
builder.setTrackTypeDisabled(trackType, choice.group == null)
if (choice.group != null) {
builder.setOverrideForType(
TrackSelectionOverride(choice.group, listOf(choice.index)),
)
}
playback.trackSelectionParameters = builder.build()
if (trackType == C.TRACK_TYPE_AUDIO) {
reportProgress(
playback.currentPosition,
!playback.isPlaying,
"AudioTrackChange",
)
}
if (trackType == C.TRACK_TYPE_TEXT) {
subtitlePreference = choice.group != null
rememberSubtitleChoice(
enabled = choice.group != null,
language = choice.group?.getFormat(choice.index)?.language,
)
subtitleAutoSelectionAttempted = true
}
if (trackType == C.TRACK_TYPE_AUDIO) playerView?.showController()
},
)
},
onDismiss = { playerView?.showController() },
)
}
@OptIn(UnstableApi::class)
private fun selectedTrackLabel(trackType: Int): String {
val playback = player ?: return ""
playback.currentTracks.groups.forEach { group ->
if (group.type != trackType) return@forEach
for (index in 0 until group.mediaTrackGroup.length) {
if (group.isTrackSelected(index)) {
return trackLabel(
group.mediaTrackGroup,
index,
showSubtitleFlag = trackType == C.TRACK_TYPE_TEXT,
)
subtitleAutoSelectionAttempted = true
}
dialog.dismiss()
}
dialog.show()
}
return ""
}
@OptIn(UnstableApi::class)
@@ -1,16 +1,18 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.components.applyTvSettingsPanelSurface
import com.ponzischeme89.memby.ui.components.tvSettingsOptionView
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
/**
* One entry in the subtitle drop-up: what it says, whether it is the choice in force, and
@@ -86,12 +88,23 @@ fun bindSubtitleMenu(
onDownload: (Int) -> Unit = {},
) {
val context = overlay.context
applyTvSettingsPanelSurface(overlay.findViewById(R.id.player_subtitle_menu))
listOf(
R.id.player_subtitle_track_heading,
R.id.player_subtitle_size_heading,
R.id.player_subtitle_download_heading,
).forEach { id -> overlay.findViewById<TextView>(id)?.setTextColor(MembyQuietText.toArgb()) }
listOf(
R.id.player_subtitle_size_rule,
R.id.player_subtitle_download_rule,
).forEach { id -> overlay.findViewById<View>(id)?.setBackgroundColor(MembyHairline.toArgb()) }
val trackContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
trackContainer.removeAllViews()
sizeContainer.removeAllViews()
overlay.findViewById<TextView>(R.id.player_subtitle_empty_notice)?.apply {
isVisible = tracksState.empty
setTextColor(MembyMutedText.toArgb())
setText(
if (tracksState.downloadable) R.string.player_subtitle_none
else R.string.player_subtitle_none_unavailable,
@@ -125,6 +138,7 @@ private fun bindDownloadSection(
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
text = downloads.status
setTextColor(MembyMutedText.toArgb())
isVisible = downloads.status.isNotBlank()
}
val container = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
@@ -141,49 +155,25 @@ private fun bindDownloadSection(
private fun menuRowsHeight(context: Context, rows: Int, maximumRows: Int): Int {
val density = context.resources.displayMetrics.density
return (rows.coerceIn(1, maximumRows) * 42 * density).toInt()
return (rows.coerceIn(1, maximumRows) * 64 * density).toInt()
}
/**
* One row of the drop-up. A [chip] sizes itself to its label for the text-size row.
* Nothing carries a tick: the current choice is the plate and the green label, and a tick on
* only some rows shifted every other label along by its width.
* One row of the drop-up. It uses the same reusable settings option as the modal player
* panels, including the persistent CURRENT badge and the shared white focus ring. [chip]
* keeps the text-size choices compact without introducing a second control style.
*/
private fun subtitleMenuOption(
context: Context,
entry: SubtitleMenuEntry,
chip: Boolean = false,
onClick: () -> Unit,
): TextView = TextView(context).apply {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 36 else 40),
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
background = context.getDrawable(
if (entry.prominent) R.drawable.next_up_primary_button
else R.drawable.player_overlay_option_background,
)
if (entry.prominent) {
setTextColor(context.getColorStateList(R.color.player_overlay_primary_option_text))
} else {
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
}
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(12), 0, dp(12), 0)
text = entry.label
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 13f else 14f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs.
isFocusable = entry.enabled
isClickable = entry.enabled
alpha = if (entry.enabled) 1f else 0.45f
isSelected = entry.selected
setOnClickListener { onClick() }
}
): View = tvSettingsOptionView(
context = context,
label = entry.label,
selected = entry.selected,
enabled = entry.enabled,
prominent = entry.prominent,
compact = chip,
onClick = onClick,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A simple outlined cog that matches Memby's other player glyphs. Kept app-owned so a
Media3 update cannot silently replace the settings entry with platform-era artwork. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,8.75a3.25,3.25 0,1 0,0 6.5a3.25,3.25 0,1 0,0 -6.5M19.1,13.35c0.05,-0.44 0.05,-0.88 0,-1.32l1.66,-1.29l-1.85,-3.2l-1.94,0.78a7.7,7.7 0,0 0,-1.15 -0.67L15.53,5.6h-3.7l-0.29,2.05c-0.4,0.18 -0.79,0.4 -1.15,0.67L8.45,7.54l-1.85,3.2l1.66,1.29a6.1,6.1 0,0 0,0 1.32L6.6,14.64l1.85,3.2l1.94,-0.78c0.36,0.27 0.75,0.49 1.15,0.67l0.29,2.05h3.7l0.29,-2.05c0.4,-0.18 0.79,-0.4 1.15,-0.67l1.94,0.78l1.85,-3.2z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.7" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Drawn by Android before Compose exists. It uses only the bundled collage, so process
startup never waits for settings, storage, Coil or a network connection. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap
android:gravity="fill"
android:src="@drawable/startup_poster_fallback" />
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#99070A0C" />
</shape>
</item>
</layer-list>
@@ -277,8 +277,7 @@
android:id="@id/exo_settings"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/exo_controls_settings_description"
android:src="@drawable/exo_ic_settings"
tools:ignore="PrivateResource" />
android:src="@drawable/ic_player_settings" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -6,7 +6,7 @@
what caps how tall the track list can grow before it scrolls.
The panel holds two screens, not three sections. Choosing a track and fetching one the
title does not have are separate questions, and at 316dp by a third of a 720p screen
title does not have are separate questions, and at 440dp by a third of a 720p screen
there is not room to ask both — stacking them squeezed the track list to a single row.
So `player_subtitle_main_section` and the results half of the download section swap:
the search row is the way in, Back is the way out, one press per level. -->
@@ -21,7 +21,7 @@
<LinearLayout
android:id="@+id/player_subtitle_menu"
android:layout_width="316dp"
android:layout_width="440dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginTop="96dp"
@@ -29,10 +29,10 @@
android:layout_marginBottom="112dp"
android:background="@drawable/player_menu_background"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp">
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:paddingBottom="16dp">
<LinearLayout
android:id="@+id/player_subtitle_main_section"
@@ -41,6 +41,7 @@
android:orientation="vertical">
<TextView
android:id="@+id/player_subtitle_track_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
@@ -83,6 +84,7 @@
</ScrollView>
<View
android:id="@+id/player_subtitle_size_rule"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
@@ -91,6 +93,7 @@
android:background="#14FFFFFF" />
<TextView
android:id="@+id/player_subtitle_size_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
@@ -131,6 +134,7 @@
android:background="#14FFFFFF" />
<TextView
android:id="@+id/player_subtitle_download_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
+7
View File
@@ -12,4 +12,11 @@
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
<!-- MainActivity alone gets the local poster wall before its first Compose frame.
Player and screensaver windows keep their black background, where a launch poster
appearing behind a video surface would be a flash rather than a welcome. -->
<style name="Theme.Memby.Launcher" parent="Theme.Memby">
<item name="android:windowBackground">@drawable/startup_window_background</item>
</style>
</resources>
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaStream
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MetadataHeroOrderTest {
@@ -68,5 +69,21 @@ class MetadataHeroOrderTest {
assertEquals(60L, metadataHeroMinutesRemaining(item))
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
assertEquals(
"60 minutes remaining",
metadataHeroProgressLabel(item, isContinueWatchingItem = true),
)
}
@Test
fun `unstarted next episode is labelled only in continue watching`() {
val item = BaseItem(
id = "next",
type = "Episode",
userData = UserItemData(playbackPositionTicks = 0L),
)
assertEquals("Next up", metadataHeroProgressLabel(item, isContinueWatchingItem = true))
assertNull(metadataHeroProgressLabel(item, isContinueWatchingItem = false))
}
}
@@ -29,9 +29,8 @@ import org.robolectric.annotation.GraphicsMode
* .\gradlew.bat :app:testDebugUnitTest --tests "*RatingsStripScreenshotTest"
* ```
*
* The marks are the reason this exists: three of them are supplied artwork and one is
* drawn, they have different aspect ratios, and whether they sit level with the score at
* both sizes is a thing to look at rather than assert.
* The marks are the reason this exists: they have different aspect ratios, and whether
* they sit level with the score at both sizes is a thing to look at rather than assert.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -65,7 +64,6 @@ class RatingsStripScreenshotTest {
capture(
"ratings-strip-wordmarks",
listOf(
MediaRating(source = "trakt", name = "Trakt", score = "83"),
MediaRating(source = "mal", name = "MyAnimeList", score = "8.1"),
MediaRating(source = "audience", name = "RT Audience", score = "91"),
),
@@ -116,6 +114,7 @@ class RatingsStripScreenshotTest {
MediaRating(source = "metacritic", name = "Metacritic", score = "81"),
MediaRating(source = "letterboxd", name = "Letterboxd", score = "4.1"),
MediaRating(source = "tmdb", name = "TMDb", score = "7.4"),
MediaRating(source = "trakt", name = "Trakt", score = "83"),
)
}
}
@@ -0,0 +1,49 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupPosterBackdropTest {
@Test
fun `poster sample balances viewer activity and recommendation rows`() {
val state = HomeUiState(
continueWatching = listOf(poster("continue-1"), poster("continue-2")),
favorites = listOf(poster("favourite-1"), poster("continue-1")),
rows = listOf(HomeRow(items = listOf(poster("recommendation-1")))),
latestMovies = listOf(poster("latest-1")),
)
assertEquals(
listOf(
"continue-1",
"favourite-1",
"recommendation-1",
"latest-1",
"continue-2",
),
startupPosterItems(state).map(BaseItem::id),
)
}
@Test
fun `poster sample excludes episode stills and titles without artwork`() {
val state = HomeUiState(
favorites = listOf(
poster("movie"),
BaseItem(id = "episode", type = "Episode", imageTags = mapOf("Primary" to "tag")),
BaseItem(id = "missing-art", type = "Series"),
),
)
assertEquals(listOf("movie"), startupPosterItems(state).map(BaseItem::id))
}
private fun poster(id: String) = BaseItem(
id = id,
name = id,
type = "Movie",
imageTags = mapOf("Primary" to "tag-$id"),
)
}
@@ -41,13 +41,6 @@ class WelcomeQuotesTest {
}
}
@Test
fun `the loading headline varies and always says something`() {
val seen = (0 until 1_000).map { randomLoadingHeadline(Random(it)) }.toSet()
assertTrue("only ${seen.size} distinct headlines", seen.size >= 5)
assertTrue(seen.none { it.isBlank() })
}
@Test
fun `login greeting trims the authenticated username`() {
assertTrue(
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB