diff --git a/CLAUDE.md b/CLAUDE.md index 1475437..3f2ebad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da1425c..da972ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 45cef05..0bb9a38 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/app/src/main/java/com/ponzischeme89/memby/data/StartupPosterSnapshotCache.kt b/app/src/main/java/com/ponzischeme89/memby/data/StartupPosterSnapshotCache.kt new file mode 100644 index 0000000..a8c4e45 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/StartupPosterSnapshotCache.kt @@ -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, + ) = 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(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 { + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt index 2c35249..c2a905c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt index 419a7c6..18ed7a0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt @@ -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(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(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 = { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 7a9c976..4cbdd69 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -288,6 +288,7 @@ internal fun FocusedHomeMetadata( homeViewModel: HomeViewModel, metadataHeroContentOrder: List, 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, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 7c69c49..47ea982 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt deleted file mode 100644 index db31dd1..0000000 --- a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt +++ /dev/null @@ -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(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 }, - ) -} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt index de4ef66..868988e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt @@ -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 = 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt b/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt index 2705018..5beadaf 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/Onboarding.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt index d04acc1..51b5ded 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt @@ -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 } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt index 2861f1c..e7f4e88 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/StartupPosterBackdrop.kt b/app/src/main/java/com/ponzischeme89/memby/ui/StartupPosterBackdrop.kt new file mode 100644 index 0000000..f4ace15 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/StartupPosterBackdrop.kt @@ -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 { + fun List.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(limit) + val seen = HashSet() + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt index 57db2cd..2cd1cd6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt new file mode 100644 index 0000000..261ca3d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt @@ -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, + 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, + initiallySelected: Set, + onSave: (Set) -> 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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt index 2970bee..918810c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -133,6 +133,7 @@ fun MediaMetadataPanel( loading: Boolean, contentOrder: List, 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, 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" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 18d6455..5b8014d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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 { + 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) { - 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) { @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt index e755530..4ae5c63 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt @@ -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(id)?.setTextColor(MembyQuietText.toArgb()) } + listOf( + R.id.player_subtitle_size_rule, + R.id.player_subtitle_download_rule, + ).forEach { id -> overlay.findViewById(id)?.setBackgroundColor(MembyHairline.toArgb()) } val trackContainer = overlay.findViewById(R.id.player_subtitle_tracks) val sizeContainer = overlay.findViewById(R.id.player_subtitle_sizes) trackContainer.removeAllViews() sizeContainer.removeAllViews() overlay.findViewById(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(R.id.player_subtitle_download_status).apply { text = downloads.status + setTextColor(MembyMutedText.toArgb()) isVisible = downloads.status.isNotBlank() } val container = overlay.findViewById(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, +) diff --git a/app/src/main/res/drawable-nodpi/ic_rating_trakt.png b/app/src/main/res/drawable-nodpi/ic_rating_trakt.png new file mode 100644 index 0000000..c748bd4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_rating_trakt.png differ diff --git a/app/src/main/res/drawable-nodpi/startup_poster_fallback.png b/app/src/main/res/drawable-nodpi/startup_poster_fallback.png new file mode 100644 index 0000000..a5c1899 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/startup_poster_fallback.png differ diff --git a/app/src/main/res/drawable/ic_player_settings.xml b/app/src/main/res/drawable/ic_player_settings.xml new file mode 100644 index 0000000..904da2d --- /dev/null +++ b/app/src/main/res/drawable/ic_player_settings.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/startup_window_background.xml b/app/src/main/res/drawable/startup_window_background.xml new file mode 100644 index 0000000..562e4b8 --- /dev/null +++ b/app/src/main/res/drawable/startup_window_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/memby_player_controls.xml b/app/src/main/res/layout/memby_player_controls.xml index efaa21c..4832b2f 100644 --- a/app/src/main/res/layout/memby_player_controls.xml +++ b/app/src/main/res/layout/memby_player_controls.xml @@ -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" /> diff --git a/app/src/main/res/layout/player_subtitle_overlay.xml b/app/src/main/res/layout/player_subtitle_overlay.xml index 4ebe276..f083bc2 100644 --- a/app/src/main/res/layout/player_subtitle_overlay.xml +++ b/app/src/main/res/layout/player_subtitle_overlay.xml @@ -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 @@ + android:paddingStart="16dp" + android:paddingTop="16dp" + android:paddingEnd="16dp" + android:paddingBottom="16dp"> false true + + + diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt index 1e28f27..a75d6f4 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt @@ -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)) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/RatingsStripScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/RatingsStripScreenshotTest.kt index ae73df8..f346f51 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/RatingsStripScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/RatingsStripScreenshotTest.kt @@ -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"), ) } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/StartupPosterBackdropTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/StartupPosterBackdropTest.kt new file mode 100644 index 0000000..478acd4 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/StartupPosterBackdropTest.kt @@ -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"), + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt index d21ce2a..6c67925 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt @@ -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( diff --git a/icon-trakt.png b/icon-trakt.png new file mode 100644 index 0000000..c748bd4 Binary files /dev/null and b/icon-trakt.png differ