0.3.02
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user