0.3.02
This commit is contained in:
@@ -1776,7 +1776,7 @@ preserve:
|
|||||||
the search row instead of on "Off", which is the state the viewer is already in. Where
|
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
|
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.
|
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
|
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
|
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
|
`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
|
**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
|
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
|
somebody's film, so there is no scrim and the panel is near-black. It uses the shared
|
||||||
the only fill on it (accent green), the choice in force is a quiet grey plate with a green
|
`TvSettingsComponents` rows: focus is a raised control with a white ring, the choice in force
|
||||||
label, and every other row has no background at all. The margins are measured off the
|
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
|
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
|
padding — and the top margin is what caps the track list before its `ScrollView` starts
|
||||||
takes the overflow via `layout_weight`) starts scrolling instead of growing up the screen.
|
scrolling instead of growing up the screen.
|
||||||
`bindSubtitleMenu` in `ui/player/SubtitleMenu.kt` fills it from plain
|
`bindSubtitleMenu` in `ui/player/SubtitleMenu.kt` fills it from plain
|
||||||
`SubtitleMenuEntry` lists so `SubtitleMenuScreenshotTest` can render the real menu with no
|
`SubtitleMenuEntry` lists so `SubtitleMenuScreenshotTest` can render the real menu with no
|
||||||
player, server or decoder → `build/screenshots/subtitles-menu/`; deciding *what* the entries
|
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
|
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.
|
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
|
**Cold start is a local poster snapshot, never a live grid.**
|
||||||
screen every launch shows and the clip was the one thing Memby owns that nobody ever saw
|
`data/StartupPosterSnapshotCache.kt` takes a balanced sample of films and series from the
|
||||||
there. It borrows the *same* cached instance — no second decoder, no second copy of the
|
active viewer's Continue Watching, favourites, server-composed recommendation rows and
|
||||||
file — and hands it back on dispose, so the next playback still opens on a prepared player.
|
latest films *after* Home's refresh has settled. On `Dispatchers.IO` it asks Coil for bounded
|
||||||
**It now gates the launcher**, which is the whole point of it: it was decoration, uncovered
|
220×330 poster requests, composes them into one 1280×720 angled wall and atomically writes a
|
||||||
whenever the app happened to be ready, and on a warm start that was a fraction of a second —
|
small JPEG under `filesDir/startup_posters/`, keyed by server, Emby user and Memby viewer.
|
||||||
so the one thing Memby owns was in practice never seen. The clip plays once from the
|
The next cold start performs only a file existence check and one asynchronous local decode;
|
||||||
beginning, its last frame is held for `LAUNCH_INTRO_HOLD_MS` (2s), and only then is the home
|
no URL is ever handed to `StartupPosterBackdrop`, and no list of poster bitmaps exists in
|
||||||
screen composed. Things to preserve: **it gates but it can never trap** — no player, a
|
composition. Snapshots refresh at most daily and the directory retains eight profiles.
|
||||||
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.
|
|
||||||
|
|
||||||
**What it says while it is opening is `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone
|
The bundled `drawable-nodpi/startup_poster_fallback.png` is a rights-safe wall of fictional
|
||||||
plus eight headlines, because this is the most-read copy in the app and five per tone meant
|
artwork. `Theme.Memby.Launcher` draws it as MainActivity's window background before Compose
|
||||||
a household saw the same sentence roughly every fifth time they switched the set on.
|
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
|
`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
|
gained a duplicate on a copy-paste, looks identical to one that did not. The greeting is
|
||||||
kept apart from the quotes and is not keyed on the tone — it names what the app is *doing*,
|
assembled separately so the authenticated name is trimmed once; the player also draws from
|
||||||
and rerolling it when the settings flow arrives would change the line mid-launch.
|
the same tone-specific pool while it prepares a programme.
|
||||||
|
|
||||||
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
|
**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
|
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ val projectNoticeText =
|
|||||||
rootProject.file("NOTICE").readText()
|
rootProject.file("NOTICE").readText()
|
||||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||||
|
|
||||||
val defaultVersionName = "0.3.01"
|
val defaultVersionName = "0.3.02"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
|
android:theme="@style/Theme.Memby.Launcher"
|
||||||
tools:ignore="DiscouragedApi"
|
tools:ignore="DiscouragedApi"
|
||||||
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
|
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
|
||||||
<intent-filter>
|
<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)
|
.callTimeout(3, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(false)
|
.retryOnConnectionFailure(false)
|
||||||
.build()
|
.build()
|
||||||
runCatching {
|
return runCatching {
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (response.code == 304) return cachedEtag
|
if (response.code == 304) return cachedEtag
|
||||||
if (!response.isSuccessful) return cachedEtag
|
if (!response.isSuccessful) return cachedEtag
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import com.ponzischeme89.memby.update.requiredUpdateSatisfied
|
|||||||
import com.ponzischeme89.memby.update.ServerUpdateService
|
import com.ponzischeme89.memby.update.ServerUpdateService
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
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
|
// 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".
|
// retired-build screen. Conflated because the only thing either says is "ask now".
|
||||||
val updateWake = remember { Channel<Unit>(Channel.CONFLATED) }
|
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 dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var confirmingExit by rememberSaveable { mutableStateOf(false) }
|
var confirmingExit by rememberSaveable { mutableStateOf(false) }
|
||||||
// SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when
|
// SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when
|
||||||
@@ -322,50 +308,35 @@ internal fun AppRoot(
|
|||||||
// the server will refuse.
|
// the server will refuse.
|
||||||
val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
|
val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
|
||||||
?: reportedRequiredUpdate
|
?: reportedRequiredUpdate
|
||||||
// The three states below that mean "still opening" used to be three separate calls
|
// One call site survives every state that still means "opening", so the local
|
||||||
// to MembyLoadingScreen, and Compose identifies a composable by where it is called
|
// poster snapshot never reloads while update/session/onboarding checks settle.
|
||||||
// from — so passing between them disposed the screen and built it again. That was
|
// There is deliberately no decorative hold: the instant the next screen is ready,
|
||||||
// only a wasted animation before; now it hands the pre-roll player back and borrows
|
// it replaces this one.
|
||||||
// it a moment later, twice, during the one stretch of a launch that is busiest.
|
val opening = when {
|
||||||
// Hoisted to one call site, the screen and its clip survive the whole cold start.
|
|
||||||
val openingQuoteStyle = when {
|
|
||||||
// A required update is a service gate, not launcher content. Once the gateway
|
// A required update is a service gate, not launcher content. Once the gateway
|
||||||
// answers, uncover it immediately instead of making the viewer finish the
|
// answers, uncover it immediately instead of making the viewer finish the
|
||||||
// decorative opening clip first. Optional prompts may wait for the intro.
|
// opening artwork first. Optional prompts can replace it as soon as ready.
|
||||||
update?.isMandatory == true -> null
|
update?.isMandatory == true -> false
|
||||||
// Memby's own clip runs to its end before anything else is drawn. It is the one
|
!initialUpdateCheckComplete -> true
|
||||||
// thing the app owns and it was, in practice, never seen: the launcher uncovered
|
update != null -> false
|
||||||
// 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
|
|
||||||
// Retired, verdict not here yet. The retired-build screen takes the frame
|
// 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
|
// 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
|
// thing there is to do about it, where a loading screen with nothing behind it
|
||||||
// reads as a television that has stopped working.
|
// reads as a television that has stopped working.
|
||||||
retiredVersion != null -> null
|
retiredVersion != null -> false
|
||||||
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
|
loaded == null -> true
|
||||||
// Ordered exactly as the screens below are: adding a profile outranks waiting
|
// 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
|
// on the onboarding answer, and swapping the two would put the loading screen
|
||||||
// over a sign-in form somebody is halfway through.
|
// over a sign-in form somebody is halfway through.
|
||||||
addingProfile -> null
|
addingProfile -> false
|
||||||
loaded.isSignedIn && onboarding == null ->
|
loaded.isSignedIn && onboarding == null -> true
|
||||||
loaded.welcomeQuoteStyle.orEmpty()
|
else -> false
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
if (openingQuoteStyle != null) {
|
if (opening) {
|
||||||
MembyLoadingScreen(
|
MembyLoadingScreen(settings = loaded)
|
||||||
quoteStyle = openingQuoteStyle,
|
|
||||||
onIntroFinished = {
|
|
||||||
LaunchIntro.played = true
|
|
||||||
introHolding = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
openingQuoteStyle != null -> Unit
|
opening -> Unit
|
||||||
update != null -> UpdateScreen(
|
update != null -> UpdateScreen(
|
||||||
update = update,
|
update = update,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ internal fun FocusedHomeMetadata(
|
|||||||
homeViewModel: HomeViewModel,
|
homeViewModel: HomeViewModel,
|
||||||
metadataHeroContentOrder: List<String>,
|
metadataHeroContentOrder: List<String>,
|
||||||
metadataHeroTimeRemainingColour: String,
|
metadataHeroTimeRemainingColour: String,
|
||||||
|
isContinueWatchingItem: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||||
@@ -300,6 +301,7 @@ internal fun FocusedHomeMetadata(
|
|||||||
loading = homeContent.loading.isNotEmpty(),
|
loading = homeContent.loading.isNotEmpty(),
|
||||||
contentOrder = metadataHeroContentOrder,
|
contentOrder = metadataHeroContentOrder,
|
||||||
timeRemainingColour = metadataHeroTimeRemainingColour,
|
timeRemainingColour = metadataHeroTimeRemainingColour,
|
||||||
|
isContinueWatchingItem = isContinueWatchingItem,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import androidx.lifecycle.repeatOnLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.Settings
|
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.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||||
@@ -222,6 +224,41 @@ internal fun HomeScreen(
|
|||||||
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
|
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
|
||||||
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
||||||
val playedChanges by homeViewModel.playedChanges.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 liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||||
val metadataHeroContentOrder by
|
val metadataHeroContentOrder by
|
||||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||||
@@ -1384,6 +1421,9 @@ internal fun HomeScreen(
|
|||||||
homeViewModel = homeViewModel,
|
homeViewModel = homeViewModel,
|
||||||
metadataHeroContentOrder = metadataHeroContentOrder,
|
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||||
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
|
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
|
||||||
|
isContinueWatchingItem = rows.firstOrNull {
|
||||||
|
it.id == focusedHomeRowId
|
||||||
|
}?.kind == MediaRowKind.CONTINUE,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(metadataHeight)
|
.height(metadataHeight)
|
||||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
// 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
|
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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -36,6 +41,7 @@ internal fun MetadataHero(
|
|||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
contentOrder: List<String> = emptyList(),
|
contentOrder: List<String> = emptyList(),
|
||||||
timeRemainingColour: String = "green",
|
timeRemainingColour: String = "green",
|
||||||
|
isContinueWatchingItem: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -73,6 +79,7 @@ internal fun MetadataHero(
|
|||||||
loading = loading,
|
loading = loading,
|
||||||
contentOrder = contentOrder,
|
contentOrder = contentOrder,
|
||||||
timeRemainingColour = timeRemainingColour,
|
timeRemainingColour = timeRemainingColour,
|
||||||
|
isContinueWatchingItem = isContinueWatchingItem,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(
|
.padding(
|
||||||
@@ -112,6 +119,16 @@ private fun MetadataHeroArtwork(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (request == null) return
|
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) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -124,7 +141,12 @@ private fun MetadataHeroArtwork(
|
|||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
alignment = Alignment.CenterEnd,
|
alignment = Alignment.CenterEnd,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = artworkScale.value
|
||||||
|
scaleY = artworkScale.value
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -151,4 +173,6 @@ private fun MetadataHeroArtwork(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val MetadataHeroArtworkCrossfadeMs = 180
|
private const val MetadataHeroArtworkCrossfadeMs = 180
|
||||||
|
private const val MetadataHeroArtworkInitialScale = 1.05f
|
||||||
|
private const val MetadataHeroArtworkZoomOutMs = 10_000
|
||||||
private const val MetadataHeroArtworkWidthFraction = 0.68f
|
private const val MetadataHeroArtworkWidthFraction = 0.68f
|
||||||
|
|||||||
@@ -99,147 +99,9 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MembyLoadingScreen(
|
internal fun MembyLoadingScreen(
|
||||||
quoteStyle: String? = null,
|
settings: Settings? = null,
|
||||||
onIntroFinished: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) }
|
StartupPosterBackdrop(settings = settings)
|
||||||
// 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),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ private fun ratingIcon(source: String): Int? = when (source.lowercase()) {
|
|||||||
"imdb" -> R.drawable.ic_rating_imdb
|
"imdb" -> R.drawable.ic_rating_imdb
|
||||||
"letterboxd" -> R.drawable.ic_rating_letterboxd
|
"letterboxd" -> R.drawable.ic_rating_letterboxd
|
||||||
"tmdb" -> R.drawable.ic_rating_tmdb
|
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||||
|
"trakt" -> R.drawable.ic_rating_trakt
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ internal fun BaseItem.toResumableMediaCardModel(
|
|||||||
// This adapter is used by the Continue Watching card. Within that merged row, an
|
// 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
|
// 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.
|
// 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? {
|
internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
|
||||||
val cleanName = name?.trim().orEmpty()
|
val cleanName = name?.trim().orEmpty()
|
||||||
val code = if (season != null && season >= 0 && episode != null && episode >= 0) {
|
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
|
* Five lines per tone meant a household saw the same sentence roughly every fifth time it
|
||||||
* the one thing every launch shows, and the pre-roll shows one too. Five lines per tone
|
* appeared, which is how a line written to be a small pleasure becomes wallpaper. The pools
|
||||||
* meant a household saw the same sentence roughly every fifth time they switched the
|
* are deliberately deep enough that a repeat inside a week is unlikely rather than
|
||||||
* 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
|
|
||||||
* guaranteed.
|
* guaranteed.
|
||||||
*
|
*
|
||||||
* Every line has to survive being read for the hundredth time, so they lean on observation
|
* 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
|
* 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
|
* short it reads as a status message, and nothing so long it overwhelms the surface using it.
|
||||||
* loading screen gives it at 13sp across 440dp.
|
|
||||||
*/
|
*/
|
||||||
private val WelcomeQuotes = mapOf(
|
private val WelcomeQuotes = mapOf(
|
||||||
WelcomeQuoteStyle.NEUTRAL to listOf(
|
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(
|
internal fun randomWelcomeQuote(
|
||||||
styleValue: String?,
|
styleValue: String?,
|
||||||
random: Random = Random.Default,
|
random: Random = Random.Default,
|
||||||
@@ -142,9 +121,6 @@ internal fun randomWelcomeQuote(
|
|||||||
return quotes[random.nextInt(quotes.size)]
|
return quotes[random.nextInt(quotes.size)]
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun randomLoadingHeadline(random: Random = Random.Default): String =
|
|
||||||
LoadingHeadlines[random.nextInt(LoadingHeadlines.size)]
|
|
||||||
|
|
||||||
internal fun loginWelcomeMessage(
|
internal fun loginWelcomeMessage(
|
||||||
username: String,
|
username: String,
|
||||||
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
|
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,
|
loading: Boolean,
|
||||||
contentOrder: List<String>,
|
contentOrder: List<String>,
|
||||||
timeRemainingColour: String,
|
timeRemainingColour: String,
|
||||||
|
isContinueWatchingItem: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier) {
|
BoxWithConstraints(modifier) {
|
||||||
@@ -162,6 +163,7 @@ fun MediaMetadataPanel(
|
|||||||
compactLayout,
|
compactLayout,
|
||||||
contentOrder,
|
contentOrder,
|
||||||
timeRemainingColour,
|
timeRemainingColour,
|
||||||
|
isContinueWatchingItem,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +209,7 @@ private fun MetadataContent(
|
|||||||
compact: Boolean,
|
compact: Boolean,
|
||||||
contentOrder: List<String>,
|
contentOrder: List<String>,
|
||||||
timeRemainingColour: String,
|
timeRemainingColour: String,
|
||||||
|
isContinueWatchingItem: Boolean,
|
||||||
) {
|
) {
|
||||||
if (item.isSchedule) {
|
if (item.isSchedule) {
|
||||||
ScheduleMetadataContent(item, contentWidth, compact)
|
ScheduleMetadataContent(item, contentWidth, compact)
|
||||||
@@ -277,7 +280,11 @@ private fun MetadataContent(
|
|||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
|
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(
|
||||||
|
item = item,
|
||||||
|
colour = timeRemainingColour,
|
||||||
|
isContinueWatchingItem = isContinueWatchingItem,
|
||||||
|
)
|
||||||
MetadataHeroSection.Summary -> Text(
|
MetadataHeroSection.Summary -> Text(
|
||||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||||
@@ -444,11 +451,14 @@ private fun MetadataStatus(item: BaseItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
|
private fun MetadataTimeRemaining(
|
||||||
|
item: BaseItem,
|
||||||
|
colour: String,
|
||||||
|
isContinueWatchingItem: Boolean,
|
||||||
|
) {
|
||||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||||
val runtime = item.runTimeTicks ?: 0L
|
val label = metadataHeroProgressLabel(item, isContinueWatchingItem) ?: return
|
||||||
val minutes = metadataHeroMinutesRemaining(item) ?: return
|
val progress = resumableProgress(position, item.runTimeTicks)
|
||||||
val progress = (position.toFloat() / runtime).coerceIn(0f, 1f)
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
@@ -463,16 +473,24 @@ private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
metadataHeroTimeRemainingLabel(minutes),
|
label,
|
||||||
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
|
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
|
||||||
fontSize = 13.sp,
|
fontSize = 12.sp,
|
||||||
lineHeight = 18.sp,
|
lineHeight = 16.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
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 =
|
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
|
||||||
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
|
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.ponzischeme89.memby.ui.player
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.animation.ValueAnimator
|
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.MainActivity
|
||||||
import com.ponzischeme89.memby.ui.EmbyOutageBanner
|
import com.ponzischeme89.memby.ui.EmbyOutageBanner
|
||||||
import com.ponzischeme89.memby.ui.ServiceAlertBanner
|
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.randomWelcomeQuote
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
import kotlinx.coroutines.Job
|
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
|
// 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.
|
// one thing reachable two ways is one thing whose two entry points drift apart.
|
||||||
val passthrough = ServiceLocator.settings.current.audioPassthroughPreference
|
val passthrough = ServiceLocator.settings.current.audioPassthroughPreference
|
||||||
val passthroughOption = "Soundbar / passthrough · ${passthroughOsdSummary(passthrough)}"
|
val selectedAudio = selectedTrackLabel(C.TRACK_TYPE_AUDIO)
|
||||||
val pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
|
val selectedSubtitle = when {
|
||||||
val options = buildList {
|
subtitlePreference == false -> "Off"
|
||||||
if (audio) add("Audio")
|
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
|
// The provider owns a trailer's captions; offering Memby's subtitle surface
|
||||||
// here would contradict the transport row that deliberately withholds it.
|
// here would contradict the transport row that deliberately withholds it.
|
||||||
if (enhancementsAvailable) add("Subtitles & appearance")
|
if (enhancementsAvailable) add(
|
||||||
add(passthroughOption)
|
TvSettingsMenuOption(
|
||||||
add(pictureOption)
|
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)
|
playerView?.hideController()
|
||||||
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
|
showTvSettingsMenu(
|
||||||
.setItems(options.toTypedArray()) { _, which ->
|
context = this,
|
||||||
when (options[which]) {
|
title = "Playback options",
|
||||||
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
|
description = "Fine-tune this video without leaving playback.",
|
||||||
"Subtitles & appearance" -> showSubtitleOverlay()
|
options = options,
|
||||||
passthroughOption -> showAudioPassthroughPicker()
|
onDismiss = { playerView?.showController() },
|
||||||
pictureOption -> showPictureModePicker()
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showAudioPassthroughPicker() {
|
private fun showAudioPassthroughPicker() {
|
||||||
val current = ServiceLocator.settings.current.audioPassthroughPreference
|
val current = ServiceLocator.settings.current.audioPassthroughPreference
|
||||||
val options = arrayOf("Auto detect", "Off", "Choose formats…")
|
|
||||||
val selected = when {
|
val selected = when {
|
||||||
current.mode == AudioPassthroughMode.AUTO -> 0
|
current.mode == AudioPassthroughMode.AUTO -> 0
|
||||||
current.codecs.isEmpty() -> 1
|
current.codecs.isEmpty() -> 1
|
||||||
else -> 2
|
else -> 2
|
||||||
}
|
}
|
||||||
AlertDialog.Builder(this)
|
showTvSettingsMenu(
|
||||||
.setTitle("Soundbar / passthrough")
|
context = this,
|
||||||
.setSingleChoiceItems(options, selected) { dialog, which ->
|
title = "Soundbar / passthrough",
|
||||||
when (which) {
|
description = "Choose how Memby sends surround sound to your television or soundbar.",
|
||||||
0 -> saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
|
options = listOf(
|
||||||
1 -> saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
|
TvSettingsMenuOption("Auto detect", "Use the formats reported by this device.", selected = selected == 0) {
|
||||||
else -> {
|
saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
|
||||||
dialog.dismiss()
|
},
|
||||||
|
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)
|
showManualPassthroughPicker(current.codecs)
|
||||||
return@setSingleChoiceItems
|
},
|
||||||
}
|
),
|
||||||
}
|
onDismiss = { playerView?.showController() },
|
||||||
dialog.dismiss()
|
)
|
||||||
}
|
|
||||||
.setNegativeButton("Cancel", null)
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
|
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
|
||||||
val selected = SurroundCodec.entries.map { it in initial }.toBooleanArray()
|
showTvSettingsMultiChoiceMenu(
|
||||||
AlertDialog.Builder(this)
|
context = this,
|
||||||
.setTitle("Formats accepted by your soundbar")
|
title = "Accepted surround formats",
|
||||||
.setMultiChoiceItems(
|
description = "Select every format your soundbar or receiver can play directly.",
|
||||||
SurroundCodec.entries.map(SurroundCodec::label).toTypedArray(),
|
labels = SurroundCodec.entries.map(SurroundCodec::label),
|
||||||
selected,
|
initiallySelected = SurroundCodec.entries.indices.filterTo(mutableSetOf()) {
|
||||||
) { _, which, checked -> selected[which] = checked }
|
SurroundCodec.entries[it] in initial
|
||||||
.setPositiveButton("Save") { _, _ ->
|
},
|
||||||
|
onSave = { selected ->
|
||||||
saveAudioPassthrough(
|
saveAudioPassthrough(
|
||||||
AudioPassthroughMode.MANUAL,
|
AudioPassthroughMode.MANUAL,
|
||||||
SurroundCodec.entries.filterIndexed { index, _ -> selected[index] }.toSet(),
|
SurroundCodec.entries.filterIndexed { index, _ -> index in selected }.toSet(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { playerView?.showController() },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
.setNegativeButton("Cancel", null)
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
|
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
|
||||||
@@ -4582,22 +4614,26 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun showPictureModePicker() {
|
private fun showPictureModePicker() {
|
||||||
val selected = selectedPictureMode()
|
val selected = selectedPictureMode()
|
||||||
AlertDialog.Builder(this)
|
showTvSettingsMenu(
|
||||||
.setTitle("Picture size")
|
context = this,
|
||||||
.setSingleChoiceItems(
|
title = "Picture size",
|
||||||
PictureMode.entries.map(PictureMode::label).toTypedArray(),
|
description = "Choose how this video fits the television screen.",
|
||||||
PictureMode.entries.indexOf(selected),
|
options = PictureMode.entries.map { mode ->
|
||||||
) { dialog, which ->
|
TvSettingsMenuOption(
|
||||||
val mode = PictureMode.entries[which]
|
label = mode.label,
|
||||||
|
selected = mode == selected,
|
||||||
|
onClick = {
|
||||||
getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
|
getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.putString(PICTURE_MODE_KEY, mode.key)
|
.putString(PICTURE_MODE_KEY, mode.key)
|
||||||
.apply()
|
.apply()
|
||||||
applyPictureMode(mode)
|
applyPictureMode(mode)
|
||||||
dialog.dismiss()
|
|
||||||
playerView?.hideController()
|
playerView?.hideController()
|
||||||
}
|
},
|
||||||
.show()
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { playerView?.showController() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5281,18 +5317,34 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index)
|
current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index)
|
||||||
}
|
}
|
||||||
}.let { if (it >= 0) it else if (trackType == C.TRACK_TYPE_TEXT) 0 else -1 }
|
}.let { if (it >= 0) it else if (trackType == C.TRACK_TYPE_TEXT) 0 else -1 }
|
||||||
val dialog = AlertDialog.Builder(this)
|
showTvSettingsMenu(
|
||||||
.setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles")
|
context = this,
|
||||||
.setSingleChoiceItems(choices.map { it.label }.toTypedArray(), selected) { dialog, which ->
|
title = if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles",
|
||||||
val choice = choices[which]
|
description = if (trackType == C.TRACK_TYPE_AUDIO) {
|
||||||
val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType)
|
"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)
|
builder.setTrackTypeDisabled(trackType, choice.group == null)
|
||||||
if (choice.group != null) {
|
if (choice.group != null) {
|
||||||
builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index)))
|
builder.setOverrideForType(
|
||||||
|
TrackSelectionOverride(choice.group, listOf(choice.index)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
playback.trackSelectionParameters = builder.build()
|
playback.trackSelectionParameters = builder.build()
|
||||||
if (trackType == C.TRACK_TYPE_AUDIO) {
|
if (trackType == C.TRACK_TYPE_AUDIO) {
|
||||||
reportProgress(playback.currentPosition, !playback.isPlaying, "AudioTrackChange")
|
reportProgress(
|
||||||
|
playback.currentPosition,
|
||||||
|
!playback.isPlaying,
|
||||||
|
"AudioTrackChange",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (trackType == C.TRACK_TYPE_TEXT) {
|
if (trackType == C.TRACK_TYPE_TEXT) {
|
||||||
subtitlePreference = choice.group != null
|
subtitlePreference = choice.group != null
|
||||||
@@ -5302,9 +5354,30 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
subtitleAutoSelectionAttempted = true
|
subtitleAutoSelectionAttempted = true
|
||||||
}
|
}
|
||||||
dialog.dismiss()
|
if (trackType == C.TRACK_TYPE_AUDIO) playerView?.showController()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { playerView?.showController() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
dialog.show()
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
package com.ponzischeme89.memby.ui.player
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.ponzischeme89.memby.R
|
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
|
* 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 = {},
|
onDownload: (Int) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = overlay.context
|
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 trackContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
|
||||||
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
|
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
|
||||||
trackContainer.removeAllViews()
|
trackContainer.removeAllViews()
|
||||||
sizeContainer.removeAllViews()
|
sizeContainer.removeAllViews()
|
||||||
overlay.findViewById<TextView>(R.id.player_subtitle_empty_notice)?.apply {
|
overlay.findViewById<TextView>(R.id.player_subtitle_empty_notice)?.apply {
|
||||||
isVisible = tracksState.empty
|
isVisible = tracksState.empty
|
||||||
|
setTextColor(MembyMutedText.toArgb())
|
||||||
setText(
|
setText(
|
||||||
if (tracksState.downloadable) R.string.player_subtitle_none
|
if (tracksState.downloadable) R.string.player_subtitle_none
|
||||||
else R.string.player_subtitle_none_unavailable,
|
else R.string.player_subtitle_none_unavailable,
|
||||||
@@ -125,6 +138,7 @@ private fun bindDownloadSection(
|
|||||||
|
|
||||||
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
|
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
|
||||||
text = downloads.status
|
text = downloads.status
|
||||||
|
setTextColor(MembyMutedText.toArgb())
|
||||||
isVisible = downloads.status.isNotBlank()
|
isVisible = downloads.status.isNotBlank()
|
||||||
}
|
}
|
||||||
val container = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
|
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 {
|
private fun menuRowsHeight(context: Context, rows: Int, maximumRows: Int): Int {
|
||||||
val density = context.resources.displayMetrics.density
|
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.
|
* One row of the drop-up. It uses the same reusable settings option as the modal player
|
||||||
* Nothing carries a tick: the current choice is the plate and the green label, and a tick on
|
* panels, including the persistent CURRENT badge and the shared white focus ring. [chip]
|
||||||
* only some rows shifted every other label along by its width.
|
* keeps the text-size choices compact without introducing a second control style.
|
||||||
*/
|
*/
|
||||||
private fun subtitleMenuOption(
|
private fun subtitleMenuOption(
|
||||||
context: Context,
|
context: Context,
|
||||||
entry: SubtitleMenuEntry,
|
entry: SubtitleMenuEntry,
|
||||||
chip: Boolean = false,
|
chip: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
): TextView = TextView(context).apply {
|
): View = tvSettingsOptionView(
|
||||||
val density = context.resources.displayMetrics.density
|
context = context,
|
||||||
fun dp(value: Int) = (value * density).toInt()
|
label = entry.label,
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
selected = entry.selected,
|
||||||
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
|
enabled = entry.enabled,
|
||||||
dp(if (chip) 36 else 40),
|
prominent = entry.prominent,
|
||||||
).apply {
|
compact = chip,
|
||||||
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
|
onClick = onClick,
|
||||||
}
|
)
|
||||||
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() }
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
android:id="@id/exo_settings"
|
||||||
style="@style/MembyPlayerControlButton"
|
style="@style/MembyPlayerControlButton"
|
||||||
android:contentDescription="@string/exo_controls_settings_description"
|
android:contentDescription="@string/exo_controls_settings_description"
|
||||||
android:src="@drawable/exo_ic_settings"
|
android:src="@drawable/ic_player_settings" />
|
||||||
tools:ignore="PrivateResource" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
what caps how tall the track list can grow before it scrolls.
|
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
|
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.
|
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:
|
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. -->
|
the search row is the way in, Back is the way out, one press per level. -->
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_subtitle_menu"
|
android:id="@+id/player_subtitle_menu"
|
||||||
android:layout_width="316dp"
|
android:layout_width="440dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|end"
|
android:layout_gravity="bottom|end"
|
||||||
android:layout_marginTop="96dp"
|
android:layout_marginTop="96dp"
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
android:layout_marginBottom="112dp"
|
android:layout_marginBottom="112dp"
|
||||||
android:background="@drawable/player_menu_background"
|
android:background="@drawable/player_menu_background"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingStart="10dp"
|
android:paddingStart="16dp"
|
||||||
android:paddingTop="10dp"
|
android:paddingTop="16dp"
|
||||||
android:paddingEnd="10dp"
|
android:paddingEnd="16dp"
|
||||||
android:paddingBottom="10dp">
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/player_subtitle_main_section"
|
android:id="@+id/player_subtitle_main_section"
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/player_subtitle_track_heading"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/player_subtitle_size_rule"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
android:background="#14FFFFFF" />
|
android:background="#14FFFFFF" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/player_subtitle_size_heading"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
@@ -131,6 +134,7 @@
|
|||||||
android:background="#14FFFFFF" />
|
android:background="#14FFFFFF" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/player_subtitle_download_heading"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="14dp"
|
android:layout_marginStart="14dp"
|
||||||
|
|||||||
@@ -12,4 +12,11 @@
|
|||||||
<item name="android:windowActionBar">false</item>
|
<item name="android:windowActionBar">false</item>
|
||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
</style>
|
</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>
|
</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.MediaStream
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MetadataHeroOrderTest {
|
class MetadataHeroOrderTest {
|
||||||
@@ -68,5 +69,21 @@ class MetadataHeroOrderTest {
|
|||||||
assertEquals(60L, metadataHeroMinutesRemaining(item))
|
assertEquals(60L, metadataHeroMinutesRemaining(item))
|
||||||
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
|
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
|
||||||
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
|
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"
|
* .\gradlew.bat :app:testDebugUnitTest --tests "*RatingsStripScreenshotTest"
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* The marks are the reason this exists: three of them are supplied artwork and one is
|
* The marks are the reason this exists: they have different aspect ratios, and whether
|
||||||
* drawn, they have different aspect ratios, and whether they sit level with the score at
|
* they sit level with the score at both sizes is a thing to look at rather than assert.
|
||||||
* both sizes is a thing to look at rather than assert.
|
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@@ -65,7 +64,6 @@ class RatingsStripScreenshotTest {
|
|||||||
capture(
|
capture(
|
||||||
"ratings-strip-wordmarks",
|
"ratings-strip-wordmarks",
|
||||||
listOf(
|
listOf(
|
||||||
MediaRating(source = "trakt", name = "Trakt", score = "83"),
|
|
||||||
MediaRating(source = "mal", name = "MyAnimeList", score = "8.1"),
|
MediaRating(source = "mal", name = "MyAnimeList", score = "8.1"),
|
||||||
MediaRating(source = "audience", name = "RT Audience", score = "91"),
|
MediaRating(source = "audience", name = "RT Audience", score = "91"),
|
||||||
),
|
),
|
||||||
@@ -116,6 +114,7 @@ class RatingsStripScreenshotTest {
|
|||||||
MediaRating(source = "metacritic", name = "Metacritic", score = "81"),
|
MediaRating(source = "metacritic", name = "Metacritic", score = "81"),
|
||||||
MediaRating(source = "letterboxd", name = "Letterboxd", score = "4.1"),
|
MediaRating(source = "letterboxd", name = "Letterboxd", score = "4.1"),
|
||||||
MediaRating(source = "tmdb", name = "TMDb", score = "7.4"),
|
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
|
@Test
|
||||||
fun `login greeting trims the authenticated username`() {
|
fun `login greeting trims the authenticated username`() {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Reference in New Issue
Block a user