0.3.22 - PINs
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.21"
|
||||
val defaultVersionName = "0.3.22"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -536,6 +536,38 @@ class EmbyRepository internal constructor(
|
||||
suspend fun devices(): List<GatewayDevice> =
|
||||
if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList()
|
||||
|
||||
suspend fun recoveryProfiles(): List<com.ponzischeme89.memby.data.model.GatewayRecoveryProfile> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot()
|
||||
return requireGateway().recoveryProfiles(snapshot.deviceId).profiles
|
||||
}
|
||||
|
||||
suspend fun recoverWithPIN(profile: com.ponzischeme89.memby.data.model.GatewayRecoveryProfile, pin: String) {
|
||||
require(ServerConfig.isGateway) { "PIN recovery requires the Memby gateway" }
|
||||
val result = requireGateway().recover(
|
||||
com.ponzischeme89.memby.data.model.GatewayRecoveryRequest(
|
||||
deviceId = profile.deviceId,
|
||||
viewerId = profile.viewer.id,
|
||||
pin = pin,
|
||||
),
|
||||
)
|
||||
require(result.token.isNotBlank() && result.userId.isNotBlank()) { "Gateway did not return a session" }
|
||||
settings.saveSession(
|
||||
ServerConfig.gatewayUrl.orEmpty(), result.token, result.userId,
|
||||
result.username.ifBlank { profile.username }, result.serverId.takeIf(String::isNotBlank),
|
||||
)
|
||||
settings.setDeviceName(profile.deviceName)
|
||||
settings.setActiveViewer(profile.viewer.id.takeUnless { profile.viewer.isMain }.orEmpty(), profile.viewer.name)
|
||||
observedSettings = settings.snapshot()
|
||||
}
|
||||
|
||||
suspend fun setViewerPIN(viewer: MembyViewer, pin: String) {
|
||||
require(ServerConfig.isGateway) { "PINs require the Memby gateway" }
|
||||
require(pin.length in 4..12) { "PIN must be 4 to 12 characters" }
|
||||
requireGateway().setViewerPIN(viewer.id, com.ponzischeme89.memby.data.model.GatewayViewerPinRequest(pin))
|
||||
}
|
||||
|
||||
suspend fun removeDevice(deviceId: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId)
|
||||
}
|
||||
@@ -840,7 +872,7 @@ class EmbyRepository internal constructor(
|
||||
"Limit" to limit.toString(),
|
||||
"Fields" to "RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
|
||||
"ImageTypeLimit" to "1",
|
||||
"EnableImageTypes" to "Backdrop,Primary,Logo",
|
||||
"EnableImageTypes" to "Backdrop,Primary,Logo,Thumb",
|
||||
"EnableUserData" to "true",
|
||||
),
|
||||
).items
|
||||
@@ -895,7 +927,7 @@ class EmbyRepository internal constructor(
|
||||
"Limit" to limit.toString(),
|
||||
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
|
||||
"ImageTypeLimit" to "1",
|
||||
"EnableImageTypes" to "Backdrop,Primary,Logo",
|
||||
"EnableImageTypes" to "Backdrop,Primary,Logo,Thumb",
|
||||
"EnableTotalRecordCount" to "false",
|
||||
"EnableUserData" to "true",
|
||||
),
|
||||
@@ -3039,7 +3071,8 @@ class EmbyRepository internal constructor(
|
||||
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
|
||||
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
|
||||
return nextEpisodeOf(
|
||||
response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
|
||||
response.item, resolveEpisodeLogoUrl(response.item),
|
||||
response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
|
||||
response.subtitlesEnabled, response.selectedSubtitleId,
|
||||
response.mediaSourceId, response.playSessionId, response.playMethod,
|
||||
response.subtitleDownloadAvailable, response.trickplayAvailable,
|
||||
@@ -3061,22 +3094,43 @@ class EmbyRepository internal constructor(
|
||||
"AdjacentTo" to itemId,
|
||||
"Fields" to "RunTimeTicks,Overview,SeriesName",
|
||||
"EnableUserData" to "true",
|
||||
"EnableImageTypes" to "Primary,Thumb",
|
||||
"EnableImageTypes" to "Primary,Thumb,Logo",
|
||||
),
|
||||
).items
|
||||
val current = episodes.indexOfFirst { it.id == itemId }.takeIf { it >= 0 } ?: return null
|
||||
val next = episodes.getOrNull(current + 1) ?: return null
|
||||
val discovery = directPlayback(next.id, next.resumePositionMs)
|
||||
return nextEpisodeOf(
|
||||
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
next, resolveEpisodeLogoUrl(next, seriesIdHint = series),
|
||||
discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles),
|
||||
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
|
||||
true, true, true, true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The next-up banner's logo, resolved from the series rather than trusted to whichever
|
||||
* screen launched playback.
|
||||
*
|
||||
* [logoUrl] reads it straight off the episode when this response carried it; when it did
|
||||
* not — an older gateway, an image-type filter that excluded Logo — falling back to the
|
||||
* series' own record is what makes the bar show the same artwork whichever row playback
|
||||
* started from. [getItemDetails] is already the single-flighted, cached lookup every
|
||||
* detail page warms on focus, so a series already opened this session costs nothing here,
|
||||
* and a series with genuinely no logo is remembered as null rather than asked again.
|
||||
*/
|
||||
private suspend fun resolveEpisodeLogoUrl(item: BaseItem, seriesIdHint: String? = null): String? {
|
||||
logoUrl(item)?.let { return it }
|
||||
val seriesId = seriesIdHint?.takeIf(String::isNotBlank)
|
||||
?: item.seriesId?.takeIf(String::isNotBlank)
|
||||
?: return null
|
||||
return runCatching { getItemDetails(seriesId) }.getOrNull()?.let(::logoUrl)
|
||||
}
|
||||
|
||||
private fun nextEpisodeOf(
|
||||
item: BaseItem,
|
||||
resolvedLogoUrl: String?,
|
||||
url: String,
|
||||
resumePositionMs: Long,
|
||||
subtitles: List<PlayableSubtitle>,
|
||||
@@ -3095,7 +3149,7 @@ class EmbyRepository internal constructor(
|
||||
seriesName = item.seriesName.orEmpty(),
|
||||
episodeCode = item.episodeCode,
|
||||
imageUrl = primaryUrl(item, maxWidth = 400),
|
||||
logoUrl = logoUrl(item),
|
||||
logoUrl = resolvedLogoUrl,
|
||||
overview = item.overview.orEmpty(),
|
||||
runtimeMs = item.runTimeTicks?.div(10_000L) ?: 0L,
|
||||
url = url,
|
||||
@@ -3246,6 +3300,23 @@ class EmbyRepository internal constructor(
|
||||
return imageUrl(id, "Backdrop", tag, maxWidth, directIndex = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The item's "Thumb" image — landscape artwork commonly carrying the title's own
|
||||
* branding, which is why Continue Watching prefers it over [backdropUrl]: that
|
||||
* branding is what lets the card skip drawing Memby's own title logo over it. An
|
||||
* episode rarely has one of its own, so this falls back to the series' Thumb exactly
|
||||
* as [backdropUrl] falls back to the series' Backdrop.
|
||||
*/
|
||||
fun thumbUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
|
||||
val (id, tag) = when {
|
||||
item.imageTags["Thumb"] != null -> item.id to item.imageTags.getValue("Thumb")
|
||||
item.parentThumbItemId != null && item.parentThumbImageTag != null ->
|
||||
item.parentThumbItemId to item.parentThumbImageTag
|
||||
else -> return null
|
||||
}
|
||||
return imageUrl(id, "Thumb", tag, maxWidth)
|
||||
}
|
||||
|
||||
/**
|
||||
* The item's "Logo" image (the stylised title treatment) from Emby metadata,
|
||||
* or null when the item has no logo. Used to show artwork in place of the
|
||||
|
||||
@@ -428,6 +428,10 @@ data class BaseItem(
|
||||
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = emptyList(),
|
||||
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
|
||||
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
|
||||
// An episode carries no Thumb of its own in most libraries; the series' is what
|
||||
// Continue Watching cards fall back to, the ParentBackdrop precedent.
|
||||
@SerialName("ParentThumbItemId") val parentThumbItemId: String? = null,
|
||||
@SerialName("ParentThumbImageTag") val parentThumbImageTag: String? = null,
|
||||
@SerialName("UserData") val userData: UserItemData? = null,
|
||||
/**
|
||||
* Chapter markers, which is where Emby records an episode's opening titles. Only the
|
||||
|
||||
@@ -87,6 +87,24 @@ data class GatewayTrailerReport(
|
||||
@Serializable
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
@Serializable
|
||||
data class GatewayViewerPinRequest(val pin: String)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRecoveryProfile(
|
||||
val deviceId: String = "",
|
||||
val deviceName: String = "",
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val viewer: MembyViewer = MembyViewer(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRecoveryProfiles(val profiles: List<GatewayRecoveryProfile> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class GatewayRecoveryRequest(val deviceId: String, val viewerId: String, val pin: String)
|
||||
|
||||
/**
|
||||
* One person under this Memby account.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,9 @@ import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfiles
|
||||
import com.ponzischeme89.memby.data.model.GatewayRecoveryRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayViewerPinRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayActiveHero
|
||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||
@@ -57,6 +60,12 @@ interface GatewayApi {
|
||||
@GET("v1/auth/session")
|
||||
suspend fun session(): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/recovery")
|
||||
suspend fun recoveryProfiles(@Query("deviceId") deviceId: String): GatewayRecoveryProfiles
|
||||
|
||||
@POST("v1/auth/recovery")
|
||||
suspend fun recover(@Body body: GatewayRecoveryRequest): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/devices")
|
||||
suspend fun devices(): GatewayDevices
|
||||
|
||||
@@ -86,6 +95,12 @@ interface GatewayApi {
|
||||
@Body body: MembyViewerRequest,
|
||||
): MembyViewer
|
||||
|
||||
@PUT("v1/viewers/{viewerId}/pin")
|
||||
suspend fun setViewerPIN(
|
||||
@Path("viewerId") viewerId: String,
|
||||
@Body body: GatewayViewerPinRequest,
|
||||
)
|
||||
|
||||
@DELETE("v1/viewers/{viewerId}")
|
||||
suspend fun deleteViewer(@Path("viewerId") viewerId: String)
|
||||
|
||||
|
||||
@@ -115,6 +115,10 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
.header("X-Memby-Components", setOf("mediaRow", "mediaGrid", "genreBrowser", "hero").joinToString(","))
|
||||
.header("X-Memby-Platform", "android-tv")
|
||||
.header("X-Memby-Device", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
// The viewer's own device clock, so a time-sensitive row (Late-night picks) agrees
|
||||
// with the greeting drawn from this same clock rather than with wherever the
|
||||
// gateway happens to be hosted or configured. See clientLocalNow on the gateway.
|
||||
.header("X-Memby-Local-Hour", java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY).toString())
|
||||
.header(
|
||||
"X-Memby-Capabilities",
|
||||
(
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
|
||||
@@ -117,6 +118,13 @@ internal fun AppRoot(
|
||||
)
|
||||
}
|
||||
var onboardingToken by remember { mutableStateOf<String?>(null) }
|
||||
var recoveryProfiles by remember { mutableStateOf<List<GatewayRecoveryProfile>?>(null) }
|
||||
var recoveryDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
var pinSetupViewer by remember { mutableStateOf<com.ponzischeme89.memby.data.model.MembyViewer?>(null) }
|
||||
LaunchedEffect(initialUpdateCheckComplete, settings?.token) {
|
||||
if (!initialUpdateCheckComplete || settings?.token != null || !ServerConfig.isGateway || recoveryDismissed) return@LaunchedEffect
|
||||
recoveryProfiles = runCatching { repo.recoveryProfiles() }.getOrElse { emptyList() }
|
||||
}
|
||||
LaunchedEffect(updateService) {
|
||||
// Every refusal wakes the loop, including a repeat of a version already handled:
|
||||
// the 401 that retires the session and the 426 that refuses the sign-in after it
|
||||
@@ -208,6 +216,10 @@ internal fun AppRoot(
|
||||
repo.invalidateSession()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(settings?.token, initialUpdateCheckComplete) {
|
||||
if (!initialUpdateCheckComplete || !ServerConfig.isGateway || settings?.isSignedIn != true) return@LaunchedEffect
|
||||
pinSetupViewer = runCatching { repo.viewers().firstOrNull { it.isMain && !it.hasPin } }.getOrNull()
|
||||
}
|
||||
LaunchedEffect(
|
||||
settings?.token,
|
||||
settings?.userId,
|
||||
@@ -301,6 +313,7 @@ internal fun AppRoot(
|
||||
// reads as a television that has stopped working.
|
||||
retiredVersion != null -> false
|
||||
loaded == null -> true
|
||||
!loaded.isSignedIn && ServerConfig.isGateway && recoveryProfiles == null -> true
|
||||
// Ordered exactly as the screens below are: adding a profile outranks waiting
|
||||
// on the onboarding answer, and swapping the two would put the loading screen
|
||||
// over a sign-in form somebody is halfway through.
|
||||
@@ -331,10 +344,19 @@ internal fun AppRoot(
|
||||
onRetry = { updateWake.trySend(Unit) },
|
||||
)
|
||||
loaded == null -> Unit
|
||||
!loaded.isSignedIn && recoveryProfiles.orEmpty().isNotEmpty() && !recoveryDismissed -> DeviceRecoveryScreen(
|
||||
profiles = recoveryProfiles.orEmpty(),
|
||||
onRecovered = { },
|
||||
onFullSignIn = { recoveryDismissed = true },
|
||||
)
|
||||
addingProfile -> SetupScreen(
|
||||
onCancel = { addingProfile = false },
|
||||
onSignedIn = { addingProfile = false },
|
||||
)
|
||||
loaded.isSignedIn && pinSetupViewer != null -> PinSetupScreen(
|
||||
viewer = pinSetupViewer!!,
|
||||
onComplete = { pinSetupViewer = null },
|
||||
)
|
||||
loaded.isSignedIn && onboarding?.completed == false && onboarding.prompted -> {
|
||||
RecommendationOnboardingScreen(
|
||||
onboarding = onboarding,
|
||||
|
||||
@@ -12,6 +12,21 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour
|
||||
else -> HomeGreetingPeriod.EVENING
|
||||
}
|
||||
|
||||
/**
|
||||
* The same four-way split the gateway's `homeForYouWindowAt` uses to title the For You
|
||||
* row, mirrored here so [HomeViewModel] can tell when that row is due to change — not to
|
||||
* word anything itself, since the greeting above only ever says "Good evening" through
|
||||
* both the evening and late-night hours. The boundaries must move in step with the
|
||||
* server's: a client that decided midnight on its own would refresh at the wrong moment,
|
||||
* which is exactly the mismatch this exists to prevent.
|
||||
*/
|
||||
internal fun homeForYouWindowId(hourOfDay: Int): String = when (hourOfDay) {
|
||||
in 5..11 -> "morning"
|
||||
in 12..16 -> "afternoon"
|
||||
in 17..22 -> "evening"
|
||||
else -> "late-night"
|
||||
}
|
||||
|
||||
/**
|
||||
* The greeting lives from the hero down through Continue Watching.
|
||||
*
|
||||
|
||||
@@ -112,6 +112,7 @@ import com.ponzischeme89.memby.ui.viewers.ViewerNameTarget
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerPicker
|
||||
import com.ponzischeme89.memby.ui.viewers.shouldOfferViewerPicker
|
||||
import com.ponzischeme89.memby.ui.viewers.viewerNameFor
|
||||
import com.ponzischeme89.memby.ui.viewers.ViewerPinEntry
|
||||
import com.ponzischeme89.memby.ui.profiles.ProfileChooser
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import androidx.tv.material3.Text
|
||||
@@ -362,6 +363,9 @@ internal fun HomeScreen(
|
||||
var viewerNameFailure by remember { mutableStateOf<String?>(null) }
|
||||
var viewerSaving by remember { mutableStateOf(false) }
|
||||
var viewerBusyId by remember { mutableStateOf<String?>(null) }
|
||||
var pinTarget by remember { mutableStateOf<MembyViewer?>(null) }
|
||||
var pinSaving by remember { mutableStateOf(false) }
|
||||
var pinFailure by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(settings.userId, settings.serverUrl) {
|
||||
viewers = if (settings.isSignedIn) repo.viewers() else emptyList()
|
||||
}
|
||||
@@ -1097,6 +1101,12 @@ internal fun HomeScreen(
|
||||
discoveryItems = discovery,
|
||||
returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
// Scoped to this profile/viewer rather than the Activity's default
|
||||
// owner: SearchViewModel's query cache and on-screen results are one
|
||||
// person's search state, and profileViewModelOwner is cleared on
|
||||
// every switchProfile/switchViewer, so the next person opens Search
|
||||
// on a fresh instance instead of inheriting the previous one's.
|
||||
viewModelStoreOwner = profileViewModelOwner,
|
||||
onSearchStarted = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "search", action = "start", screen = "search",
|
||||
@@ -1942,6 +1952,7 @@ internal fun HomeScreen(
|
||||
onClose = { showViewerManage = false },
|
||||
canAddViewer = viewers.count { !it.isMain } < MAX_SHADOW_VIEWERS,
|
||||
busyViewerId = viewerBusyId,
|
||||
onSetPin = { pinTarget = it; pinFailure = null; showViewerManage = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1984,6 +1995,28 @@ internal fun HomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
pinTarget?.let { viewer ->
|
||||
BackHandler(onBack = { pinTarget = null })
|
||||
Box(Modifier.fillMaxSize().zIndex(22f).background(MembySurface)) {
|
||||
ViewerPinEntry(
|
||||
viewer = viewer,
|
||||
saving = pinSaving,
|
||||
failure = pinFailure,
|
||||
onCancel = { pinTarget = null },
|
||||
onConfirm = { pin ->
|
||||
if (!pinSaving) {
|
||||
pinSaving = true
|
||||
scope.launch {
|
||||
runCatching { repo.setViewerPIN(viewer, pin) }
|
||||
.onSuccess { pinTarget = null; refreshViewers() }
|
||||
.onFailure { pinFailure = "That PIN could not be saved." }
|
||||
pinSaving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (userQuickActionsVisible) {
|
||||
val closeUserQuickActions: () -> Unit = {
|
||||
userQuickActionsVisible = false
|
||||
@@ -2778,4 +2811,3 @@ internal fun HomeScreen(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -239,6 +239,21 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
flushAnalytics()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
// The For You row is titled from the viewer's own clock, exactly as the greeting
|
||||
// above it is (see homeForYouWindowId). A set left open across a boundary — the
|
||||
// case this loop exists for — must not go on showing "Late-night picks for you"
|
||||
// once the greeting above it has quietly become "Good morning". `null` on the
|
||||
// first tick primes the value without refreshing, since the rows already on
|
||||
// screen were loaded for the window in force right now.
|
||||
var currentWindow: String? = null
|
||||
while (true) {
|
||||
val window = homeForYouWindowId(currentLocalHour())
|
||||
if (currentWindow != null && currentWindow != window) refreshAll()
|
||||
currentWindow = window
|
||||
delay(millisUntilNextLocalMinute())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
|
||||
@@ -1079,6 +1094,18 @@ internal fun detailMetadataComplete(itemId: String, metadata: BaseItem?): Boolea
|
||||
* `for-you:` rows are intentionally not used as that signal: they are assembled separately
|
||||
* by the gateway and may be present while the engine cache is still cold.
|
||||
*/
|
||||
/** The viewer's own device clock, hour of day — see [homeForYouWindowId]. */
|
||||
private fun currentLocalHour(): Int =
|
||||
java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
|
||||
|
||||
/**
|
||||
* How long until the next minute boundary, so the For You window watcher wakes on the
|
||||
* clock rather than drifting by however long it has been left running — the same reason
|
||||
* [HomeClock] paces its own tick this way.
|
||||
*/
|
||||
private fun millisUntilNextLocalMinute(): Long =
|
||||
60_000L - (System.currentTimeMillis() % 60_000L)
|
||||
|
||||
internal fun mergeFreshHomeRows(
|
||||
previous: List<HomeRow>,
|
||||
incoming: List<HomeRow>,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var confirmation by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Create your Memby PIN", color = MembyOnSurface, fontSize = 32.sp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
"This PIN belongs to your Memby profile and can be used to sign back in on this device, even after you log out or reinstall Memby.",
|
||||
color = MembyQuietText, fontSize = 18.sp,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PinField(pin, { pin = it }, "PIN")
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PinField(confirmation, { confirmation = it }, "Confirm PIN")
|
||||
error?.let { Text(it, color = MembyAccent) }
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Button(enabled = !saving && pin.length in 4..12 && pin == confirmation, onClick = {
|
||||
error = when {
|
||||
pin != confirmation -> "The PINs do not match."
|
||||
else -> null
|
||||
}
|
||||
if (error != null) return@Button
|
||||
saving = true
|
||||
scope.launch {
|
||||
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
|
||||
.onSuccess { onComplete() }
|
||||
.onFailure { error = "That PIN could not be saved." }
|
||||
saving = false
|
||||
}
|
||||
}) { Text("Save PIN") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinField(value: String, onValueChange: (String) -> Unit, label: String) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { onValueChange(it.filter(Char::isDigit).take(12)) },
|
||||
textStyle = TextStyle(color = MembyOnSurface, fontSize = 22.sp),
|
||||
cursorBrush = SolidColor(MembyAccent),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(260.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp),
|
||||
decorationBox = { field -> if (value.isEmpty()) Text(label, color = MembyQuietText); field() },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.GatewayRecoveryProfile
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun DeviceRecoveryScreen(
|
||||
profiles: List<GatewayRecoveryProfile>,
|
||||
onRecovered: () -> Unit,
|
||||
onFullSignIn: () -> Unit,
|
||||
) {
|
||||
var selected by remember { mutableStateOf<GatewayRecoveryProfile?>(null) }
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val chosen = selected
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MembySurface).padding(72.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Welcome back", color = MembyOnSurface, fontSize = 32.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("This TV is already registered with Memby.", color = MembyQuietText, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
profiles.distinctBy { it.viewer.id }.forEach { profile ->
|
||||
Column(Modifier.width(150.dp).background(MembyControlSurface, RoundedCornerShape(14.dp)).clickable { selected = profile; pin = ""; error = null }.padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(profile.viewer.initials, color = MembyAccent, fontSize = 36.sp, fontWeight = FontWeight.Bold)
|
||||
Text(profile.viewer.name, color = MembyOnSurface, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
chosen?.let { profile ->
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText)
|
||||
if (profile.viewer.hasPin) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = androidx.compose.ui.text.TextStyle(color = MembyOnSurface, fontSize = 22.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
|
||||
}
|
||||
error?.let { Text(it, color = MembyAccent) }
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Button(onClick = {
|
||||
if (profile.viewer.hasPin && pin.isBlank()) { error = "Enter your PIN"; return@Button }
|
||||
scope.launch {
|
||||
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }.onSuccess { onRecovered() }.onFailure { error = "That PIN was not accepted." }
|
||||
}
|
||||
}) { Text("Continue") }
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Button(onClick = onFullSignIn) { Text("Use full Emby sign-in") }
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ data class ResumableMediaCardModel(
|
||||
val episodeNumber: Int? = null,
|
||||
val playbackPositionTicks: Long? = null,
|
||||
val runtimeTicks: Long? = null,
|
||||
val thumbUrl: String? = null,
|
||||
val backdropUrl: String? = null,
|
||||
val primaryUrl: String? = null,
|
||||
val played: Boolean = false,
|
||||
@@ -76,6 +77,7 @@ data class ResumableMediaCardModel(
|
||||
}
|
||||
|
||||
internal fun BaseItem.toResumableMediaCardModel(
|
||||
thumbUrl: String?,
|
||||
backdropUrl: String?,
|
||||
primaryUrl: String?,
|
||||
): ResumableMediaCardModel {
|
||||
@@ -95,6 +97,7 @@ internal fun BaseItem.toResumableMediaCardModel(
|
||||
episodeNumber = indexNumber.takeIf { isEpisode },
|
||||
playbackPositionTicks = playbackPosition,
|
||||
runtimeTicks = runTimeTicks,
|
||||
thumbUrl = thumbUrl,
|
||||
backdropUrl = backdropUrl,
|
||||
primaryUrl = primaryUrl,
|
||||
played = played,
|
||||
@@ -125,6 +128,19 @@ internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork priority for a resumable card: portrait shape prefers the poster, landscape
|
||||
* prefers Thumb — which commonly carries the title's own branding, the reason this card
|
||||
* never overlays Memby's own title logo — and falls back to Backdrop and then Primary
|
||||
* so a missing Thumb never leaves the card blank.
|
||||
*/
|
||||
internal fun resumableArtworkUrl(model: ResumableMediaCardModel, portraitArtwork: Boolean): String? =
|
||||
if (portraitArtwork) {
|
||||
model.primaryUrl ?: model.backdropUrl
|
||||
} else {
|
||||
model.thumbUrl ?: model.backdropUrl ?: model.primaryUrl
|
||||
}
|
||||
|
||||
internal fun resumableProgress(positionTicks: Long?, runtimeTicks: Long?): Float {
|
||||
val runtime = runtimeTicks ?: return 0f
|
||||
if (runtime <= 0L) return 0f
|
||||
@@ -170,11 +186,7 @@ fun ResumableMediaCard(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val artworkUrl = if (portraitArtwork) {
|
||||
model.primaryUrl ?: model.backdropUrl
|
||||
} else {
|
||||
model.backdropUrl ?: model.primaryUrl
|
||||
}
|
||||
val artworkUrl = resumableArtworkUrl(model, portraitArtwork)
|
||||
val usesPrimaryArtwork = artworkUrl != null && artworkUrl == model.primaryUrl
|
||||
val nowEpochMillis by produceState(initialValue = System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
|
||||
@@ -43,6 +43,7 @@ fun ContinueWatchingCard(
|
||||
val repository = ServiceLocator.repository
|
||||
val model = remember(item) {
|
||||
item.toResumableMediaCardModel(
|
||||
thumbUrl = repository.thumbUrl(item, 720),
|
||||
backdropUrl = repository.backdropUrl(item, 720),
|
||||
primaryUrl = repository.primaryUrl(item, 480),
|
||||
)
|
||||
|
||||
@@ -73,7 +73,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
@@ -143,9 +145,16 @@ fun SearchScreen(
|
||||
onContentFocused: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
// Defaults to whatever owner is already in scope (the Activity), which is exactly
|
||||
// wrong for this ViewModel: its query cache and in-flight results hold one viewer's
|
||||
// search state, so the caller must pass the profile-scoped owner HomeScreen clears on
|
||||
// every switchProfile/switchViewer, or the next person to open Search inherits the
|
||||
// previous person's cached results and on-screen query.
|
||||
viewModelStoreOwner: ViewModelStoreOwner = requireNotNull(LocalViewModelStoreOwner.current),
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
val viewModel: SearchViewModel = viewModel(
|
||||
viewModelStoreOwner = viewModelStoreOwner,
|
||||
factory = remember(repository) { SearchViewModelFactory(repository) },
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
package com.ponzischeme89.memby.ui.viewers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
|
||||
/**
|
||||
* The rules behind naming a person, kept pure so they can be tested without a television.
|
||||
@@ -114,3 +144,27 @@ internal fun viewerIsEditable(viewer: MembyViewer): Boolean = !viewer.isMain
|
||||
*/
|
||||
internal fun viewerFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
|
||||
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
|
||||
|
||||
@Composable
|
||||
internal fun ViewerPinEntry(
|
||||
viewer: MembyViewer,
|
||||
saving: Boolean,
|
||||
failure: String?,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize().background(MembySurface), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text("Set a PIN for ${viewer.name}", color = MembyOnSurface, fontSize = 28.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Use 4 to 12 digits to protect this profile.", color = MembyQuietText)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
BasicTextField(pin, { pin = it.filter(Char::isDigit).take(12) }, textStyle = TextStyle(color = MembyOnSurface, fontSize = 24.sp), cursorBrush = SolidColor(MembyAccent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.width(220.dp).background(MembyControlSurface, RoundedCornerShape(8.dp)).padding(14.dp))
|
||||
failure?.let { Text(it, color = MembyAccent) }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ViewerActionButton(label = "Cancel", icon = MembyIcon.Close, onClick = onCancel)
|
||||
ViewerActionButton(label = "Save PIN", icon = MembyIcon.Check, onClick = { onConfirm(pin) }, enabled = !saving && pin.length in 4..12, emphasised = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ fun ViewerManageScreen(
|
||||
canAddViewer: Boolean = true,
|
||||
/** The viewer a request is in flight for, so a second press cannot start a second one. */
|
||||
busyViewerId: String? = null,
|
||||
onSetPin: (MembyViewer) -> Unit = {},
|
||||
) {
|
||||
// Ids come off a wire and a keyed list throws on a repeat — see ui/ListKeys.kt.
|
||||
val people = remember(viewers) { viewers.distinctForKeys(MembyViewer::id) }
|
||||
@@ -130,6 +131,7 @@ fun ViewerManageScreen(
|
||||
removalReturnIndex = index
|
||||
pendingRemoval = viewer
|
||||
},
|
||||
onSetPin = { onSetPin(viewer) },
|
||||
renameModifier = Modifier.focusRequester(renameFocus[index]),
|
||||
)
|
||||
}
|
||||
@@ -186,6 +188,7 @@ private fun ViewerManageRow(
|
||||
busy: Boolean,
|
||||
onRename: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSetPin: () -> Unit,
|
||||
renameModifier: Modifier = Modifier,
|
||||
) {
|
||||
var hasFocus by remember { mutableStateOf(false) }
|
||||
@@ -255,6 +258,13 @@ private fun ViewerManageRow(
|
||||
enabled = !busy,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
ViewerActionButton(
|
||||
label = if (viewer.hasPin) "Change PIN" else "Set PIN",
|
||||
icon = MembyIcon.Pin,
|
||||
onClick = onSetPin,
|
||||
enabled = !busy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,18 @@ class HomeGreetingTest {
|
||||
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(17))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `for you window id matches the gateway's boundaries`() {
|
||||
assertEquals("late-night", homeForYouWindowId(4))
|
||||
assertEquals("morning", homeForYouWindowId(5))
|
||||
assertEquals("morning", homeForYouWindowId(11))
|
||||
assertEquals("afternoon", homeForYouWindowId(12))
|
||||
assertEquals("afternoon", homeForYouWindowId(16))
|
||||
assertEquals("evening", homeForYouWindowId(17))
|
||||
assertEquals("evening", homeForYouWindowId(22))
|
||||
assertEquals("late-night", homeForYouWindowId(23))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `greeting is shown from the hero through continue watching`() {
|
||||
val rows = listOf("featured", "continue", "latest")
|
||||
|
||||
@@ -18,7 +18,7 @@ class ResumableMediaCardTest {
|
||||
seriesName = "The Show",
|
||||
parentIndexNumber = 1,
|
||||
indexNumber = 1,
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertEquals("The Show", model.title)
|
||||
assertEquals("S01E01 · Pilot", model.episodeLabel)
|
||||
@@ -70,7 +70,7 @@ class ResumableMediaCardTest {
|
||||
name = "The Next Episode",
|
||||
type = "Episode",
|
||||
userData = UserItemData(playbackPositionTicks = 0L),
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertTrue(model.isNextUp)
|
||||
assertEquals(0f, model.progress)
|
||||
@@ -85,7 +85,7 @@ class ResumableMediaCardTest {
|
||||
name = "The Current Episode",
|
||||
type = "Episode",
|
||||
userData = UserItemData(playbackPositionTicks = 10_000L),
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertFalse(model.isNextUp)
|
||||
assertNull(model.nextUpLabel)
|
||||
@@ -98,13 +98,46 @@ class ResumableMediaCardTest {
|
||||
name = "A Film",
|
||||
type = "Movie",
|
||||
userData = UserItemData(playbackPositionTicks = 0L),
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertFalse(model.isNextUp)
|
||||
assertFalse(model.showProgressTrack)
|
||||
assertNull(model.nextUpLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `landscape artwork prefers thumb over backdrop and primary`() {
|
||||
val model = ResumableMediaCardModel(
|
||||
id = "1",
|
||||
title = "A Show",
|
||||
thumbUrl = "thumb",
|
||||
backdropUrl = "backdrop",
|
||||
primaryUrl = "primary",
|
||||
)
|
||||
assertEquals("thumb", resumableArtworkUrl(model, portraitArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `landscape artwork falls back to backdrop then primary when thumb is missing`() {
|
||||
val backdropOnly = ResumableMediaCardModel(id = "1", title = "A Show", backdropUrl = "backdrop")
|
||||
assertEquals("backdrop", resumableArtworkUrl(backdropOnly, portraitArtwork = false))
|
||||
|
||||
val primaryOnly = ResumableMediaCardModel(id = "1", title = "A Show", primaryUrl = "primary")
|
||||
assertEquals("primary", resumableArtworkUrl(primaryOnly, portraitArtwork = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `portrait artwork ignores thumb`() {
|
||||
val model = ResumableMediaCardModel(
|
||||
id = "1",
|
||||
title = "A Show",
|
||||
thumbUrl = "thumb",
|
||||
backdropUrl = "backdrop",
|
||||
primaryUrl = "primary",
|
||||
)
|
||||
assertEquals("primary", resumableArtworkUrl(model, portraitArtwork = true))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_SECOND = 1_000L
|
||||
const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L
|
||||
|
||||
Reference in New Issue
Block a user