This commit is contained in:
ponzischeme89
2026-08-25 22:46:01 +12:00
parent 772635784c
commit d0f3ed94de
14 changed files with 119 additions and 81 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?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.23" val defaultVersionName = "0.3.24"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+3 -2
View File
@@ -68,9 +68,10 @@
android:name=".MembyApp" android:name=".MembyApp"
android:allowBackup="true" android:allowBackup="true"
android:banner="@drawable/app_banner" android:banner="@drawable/app_banner"
android:icon="@drawable/memby_mark" <!-- The launcher icon is circular; the square mark remains an in-app asset. -->
android:icon="@drawable/glossy_m"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@drawable/memby_mark" android:roundIcon="@drawable/glossy_m"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby"> android:theme="@style/Theme.Memby">
@@ -2200,6 +2200,28 @@ class EmbyRepository internal constructor(
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
) )
/**
* Builds the request used by a real Play action.
*
* Episode rows are intentionally lightweight and some entry points can therefore omit
* the parent-logo identity. Resolve that identity from the associated series before the
* player opens, so the immediate and server-resolved launch paths receive the same title
* artwork regardless of which row supplied the episode.
*/
suspend fun playbackRequestForLaunch(item: BaseItem): PlaybackRequest {
val request = playbackRequest(item)
if (!item.isEpisode || request.logoUrl != null) return request
val seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: return request
val seriesLogo = try {
getItemDetails(seriesId)?.let(::logoUrl)
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
null
}
return request.copy(logoUrl = seriesLogo)
}
/** /**
* Returns a prefetched stream if one is sitting ready for [request], without suspending * Returns a prefetched stream if one is sitting ready for [request], without suspending
* and without starting a request. This is what lets the launcher tell instantly whether * and without starting a request. This is what lets the launcher tell instantly whether
@@ -2222,6 +2244,7 @@ class EmbyRepository internal constructor(
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId), localPositionMs = localResumePositionMs(request.itemId),
), ),
logoUrl = request.logoUrl ?: entry.playable.logoUrl,
) )
} finally { } finally {
playableMutex.unlock() playableMutex.unlock()
@@ -2256,6 +2279,7 @@ class EmbyRepository internal constructor(
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId), localPositionMs = localResumePositionMs(request.itemId),
), ),
logoUrl = request.logoUrl ?: cached.logoUrl,
) )
} }
if (inFlight != null) { if (inFlight != null) {
@@ -2271,6 +2295,7 @@ class EmbyRepository internal constructor(
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId), localPositionMs = localResumePositionMs(request.itemId),
), ),
logoUrl = request.logoUrl ?: resolved.logoUrl,
).also { ).also {
playableMutex.withLock { playableCache.remove(request.itemId) } playableMutex.withLock { playableCache.remove(request.itemId) }
} }
@@ -407,9 +407,6 @@ internal fun HomeScreen(
val destinationFocus = remember { val destinationFocus = remember {
mutableMapOf<BrowseDestination, Pair<String, String>>() mutableMapOf<BrowseDestination, Pair<String, String>>()
} }
var showForYouNudge by rememberSaveable(settings.activeProfileId) {
mutableStateOf(!settings.hasOpenedForYou)
}
// Each destination owns a stable requester. Moving one requester between rail items // Each destination owns a stable requester. Moving one requester between rail items
// can leave a key event pointing at the previously selected item for a frame. // can leave a key event pointing at the previously selected item for a frame.
val navigationFocusRequesters = remember { val navigationFocusRequesters = remember {
@@ -633,15 +630,19 @@ internal fun HomeScreen(
// throws before one is started — a malformed cached item, an activity result // throws before one is started — a malformed cached item, an activity result
// registry that has already been torn down — would leave Play dead for the rest // registry that has already been torn down — would leave Play dead for the rest
// of the session with nothing on screen to say why. // of the session with nothing on screen to say why.
// Logo enrichment may need the associated series record. Keep it in the same
// cancellable launch job as stream resolution so Back abandons the whole hand-off.
resolveJob = scope.launch {
try {
val prepared = runCatching { val prepared = runCatching {
val request = repo.playbackRequest(item) val request = repo.playbackRequestForLaunch(item)
request to repo.readyPlayableForLaunch(request) request to repo.readyPlayableForLaunch(request)
}.getOrElse { }.getOrElse {
recordPlaybackFailed() recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback() homeViewModel.resumeAnalyticsAfterPlayback()
return@playItem return@launch
} }
val request = prepared.first val request = prepared.first
val ready = prepared.second val ready = prepared.second
@@ -665,13 +666,11 @@ internal fun HomeScreen(
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback() homeViewModel.resumeAnalyticsAfterPlayback()
} }
return@playItem return@launch
} }
// Only the route that waits on the server shows the launcher's own loading screen. // Only the route that waits on the server shows the launcher's own loading screen.
// The route that hands over immediately would only be showing it behind the player. // The route that hands over immediately would only be showing it behind the player.
resolvingItem = item resolvingItem = item
resolveJob = scope.launch {
try {
runCatching { runCatching {
val playable = ready ?: run { val playable = ready ?: run {
var timeout: TimeoutCancellationException? = null var timeout: TimeoutCancellationException? = null
@@ -898,16 +897,6 @@ internal fun HomeScreen(
) )
} }
val latestJourneyScreen by rememberUpdatedState(journeyScreen) val latestJourneyScreen by rememberUpdatedState(journeyScreen)
LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) {
if (
showForYouNudge &&
selectedDestination == BrowseDestination.HOME &&
!settings.hasOpenedForYou
) {
delay(5.seconds)
showForYouNudge = false
}
}
LaunchedEffect( LaunchedEffect(
selectedDestination, selectedDestination,
contextualHeroItems.firstOrNull()?.item?.id, contextualHeroItems.firstOrNull()?.item?.id,
@@ -2775,15 +2764,6 @@ internal fun HomeScreen(
modifier = Modifier.fillMaxSize().zIndex(9f), modifier = Modifier.fillMaxSize().zIndex(9f),
) )
} }
ForYouNudgeBanner(
visible = showForYouNudge &&
selectedDestination == BrowseDestination.HOME &&
!settings.hasOpenedForYou &&
liveMaintenance == null,
username = settings.username,
shortName = settings.shortName,
modifier = Modifier.align(Alignment.TopCenter),
)
// Emby has stopped answering. Persistent, unlike the news bar below it, because // Emby has stopped answering. Persistent, unlike the news bar below it, because
// it describes a state rather than an event: a set switched on midway through an // it describes a state rather than an event: a set switched on midway through an
// outage was never told, and this is the only thing that says why nothing plays. // outage was never told, and this is the only thing that says why nothing plays.
@@ -2809,5 +2789,3 @@ internal fun HomeScreen(
} }
} }
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -16,6 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text import androidx.tv.material3.Text
@@ -51,13 +53,18 @@ internal fun PinBoxesField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDone: () -> Unit = {},
) { ) {
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(PIN_LENGTH)) }, onValueChange = { onValueChange(it.filter(Char::isDigit).take(PIN_LENGTH)) },
textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp), textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent), cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onDone() }),
modifier = modifier, modifier = modifier,
decorationBox = { decorationBox = {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -51,7 +51,18 @@ internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
modifier = Modifier.width(460.dp), modifier = Modifier.width(460.dp),
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
PinBoxesField(pin, { pin = it; error = null }) val submitPin = {
if (!saving && pin.length == PIN_LENGTH) {
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
}
}
PinBoxesField(pin, { pin = it; error = null }, onDone = submitPin)
error?.let { error?.let {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text(it, color = MembyAccent, fontSize = 13.sp) Text(it, color = MembyAccent, fontSize = 13.sp)
@@ -60,15 +71,7 @@ internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
Button( Button(
enabled = !saving && pin.length == PIN_LENGTH, enabled = !saving && pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = { onClick = submitPin,
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") } ) { Text("Save PIN") }
} }
} }
@@ -72,7 +72,19 @@ internal fun DeviceRecoveryScreen(
Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText, fontSize = 14.sp) Text(if (profile.viewer.hasPin) "Enter your PIN" else "Continue as ${profile.viewer.name}", color = MembyQuietText, fontSize = 14.sp)
if (profile.viewer.hasPin) { if (profile.viewer.hasPin) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
PinBoxesField(pin, { pin = it; error = null }) PinBoxesField(
pin,
{ pin = it; error = null },
onDone = {
if (pin.length == PIN_LENGTH) {
scope.launch {
runCatching { ServiceLocator.repository.recoverWithPIN(profile, pin) }
.onSuccess { onRecovered() }
.onFailure { error = "That PIN was not accepted." }
}
}
},
)
} }
error?.let { error?.let {
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@@ -2704,7 +2704,10 @@ class PlayerActivity : ComponentActivity() {
/** /**
* True when Left and Right have nothing else to mean, and there is something to seek. * True when Left and Right have nothing else to mean, and there is something to seek.
* *
* The transport being up is the important half: with the controls visible those keys * The transport being up is the important half. Use [transportVisible], the visibility
* listener's state, rather than Media3's fully-visible query: during the show/hide
* animation the latter can still say hidden while the controls already own the remote.
* With the controls visible those keys
* belong to whatever holds focus the time bar, or the row of buttons and taking * belong to whatever holds focus the time bar, or the row of buttons and taking
* them would leave the viewer unable to reach the subtitle or cast controls at all. * them would leave the viewer unable to reach the subtitle or cast controls at all.
* A stream that cannot be sought (a live recording still being written, a container * A stream that cannot be sought (a live recording still being written, a container
@@ -2718,7 +2721,7 @@ class PlayerActivity : ComponentActivity() {
playback.isCurrentMediaItemSeekable && playback.isCurrentMediaItemSeekable &&
playback.duration > 0L && playback.duration > 0L &&
playback.duration != C.TIME_UNSET && playback.duration != C.TIME_UNSET &&
playerView?.isControllerFullyVisible != true && !transportVisible &&
!castPanelVisible.value && !castPanelVisible.value &&
subtitleOverlay?.isVisible != true && subtitleOverlay?.isVisible != true &&
creditsView?.isVisible != true && creditsView?.isVisible != true &&
@@ -449,7 +449,10 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// viewer typed exactly. This matters especially in the one-column list, // viewer typed exactly. This matters especially in the one-column list,
// where tenth place is ten rows away rather than the second grid row. // where tenth place is ten rows away rather than the second grid row.
val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items val ranked = if (_state.value.results.isEmpty()) rankSearchResults(term, items) else items
_state.update { it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null) } // Keep the loading signal alive after the first snapshot. The stream may still
// be waiting on Sonarr or Radarr, but the cards already on screen remain fully
// focusable and navigable while those additional results arrive.
_state.update { it.copy(results = ranked, isLoading = true, hasSearched = true, errorMessage = null) }
} }
} }
.onFailure { error -> .onFailure { error ->
@@ -463,6 +466,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
} }
if (_state.value.genre == null && _state.value.query.trim() == term) { if (_state.value.genre == null && _state.value.query.trim() == term) {
_state.update { it.copy(isLoading = false) }
cache[term] = _state.value.results cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(term) } viewModelScope.launch { repository.recordSearch(term) }
if (_state.value.requestMode) loadRequestCandidates(term) if (_state.value.requestMode) loadRequestCandidates(term)
@@ -49,6 +49,7 @@ import com.ponzischeme89.memby.ui.search.SearchUiState
import com.ponzischeme89.memby.ui.search.shouldSearch import com.ponzischeme89.memby.ui.search.shouldSearch
import com.ponzischeme89.memby.ui.requests.RequestCard import com.ponzischeme89.memby.ui.requests.RequestCard
import com.ponzischeme89.memby.ui.requests.RequestActionRequest import com.ponzischeme89.memby.ui.requests.RequestActionRequest
import com.ponzischeme89.memby.ui.requests.RequestStatus
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.MembyIcon
@@ -153,7 +153,11 @@ internal fun ViewerPinEntry(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Use a 4-digit PIN to protect this profile.", color = MembyQuietText) Text("Use a 4-digit PIN to protect this profile.", color = MembyQuietText)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
PinBoxesField(pin, { pin = it }) PinBoxesField(
pin,
{ pin = it },
onDone = { if (!saving && pin.length == PIN_LENGTH) onConfirm(pin) },
)
failure?.let { Text(it, color = MembyAccent) } failure?.let { Text(it, color = MembyAccent) }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+1 -1
View File
@@ -1 +1 @@
0.1.74 0.1.75