0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+1 -1
View File
@@ -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