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?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.23"
val defaultVersionName = "0.3.24"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.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:allowBackup="true"
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:roundIcon="@drawable/memby_mark"
android:roundIcon="@drawable/glossy_m"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
@@ -2200,6 +2200,28 @@ class EmbyRepository internal constructor(
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
* 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,
localPositionMs = localResumePositionMs(request.itemId),
),
logoUrl = request.logoUrl ?: entry.playable.logoUrl,
)
} finally {
playableMutex.unlock()
@@ -2256,6 +2279,7 @@ class EmbyRepository internal constructor(
requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
),
logoUrl = request.logoUrl ?: cached.logoUrl,
)
}
if (inFlight != null) {
@@ -2271,6 +2295,7 @@ class EmbyRepository internal constructor(
requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
),
logoUrl = request.logoUrl ?: resolved.logoUrl,
).also {
playableMutex.withLock { playableCache.remove(request.itemId) }
}
@@ -407,9 +407,6 @@ internal fun HomeScreen(
val destinationFocus = remember {
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
// can leave a key event pointing at the previously selected item for a frame.
val navigationFocusRequesters = remember {
@@ -633,45 +630,47 @@ internal fun HomeScreen(
// 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
// of the session with nothing on screen to say why.
val prepared = runCatching {
val request = repo.playbackRequest(item)
request to repo.readyPlayableForLaunch(request)
}.getOrElse {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
return@playItem
}
val request = prepared.first
val ready = prepared.second
if (ready == null && request.resumePositionMs > 0L) {
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.intent(
context = context,
request = request,
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
)
}
if (launched.isFailure) {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
return@playItem
}
// 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.
resolvingItem = item
// 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 request = repo.playbackRequestForLaunch(item)
request to repo.readyPlayableForLaunch(request)
}.getOrElse {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
return@launch
}
val request = prepared.first
val ready = prepared.second
if (ready == null && request.resumePositionMs > 0L) {
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.intent(
context = context,
request = request,
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
)
}
if (launched.isFailure) {
recordPlaybackFailed()
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
return@launch
}
// 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.
resolvingItem = item
runCatching {
val playable = ready ?: run {
var timeout: TimeoutCancellationException? = null
@@ -898,16 +897,6 @@ internal fun HomeScreen(
)
}
val latestJourneyScreen by rememberUpdatedState(journeyScreen)
LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) {
if (
showForYouNudge &&
selectedDestination == BrowseDestination.HOME &&
!settings.hasOpenedForYou
) {
delay(5.seconds)
showForYouNudge = false
}
}
LaunchedEffect(
selectedDestination,
contextualHeroItems.firstOrNull()?.item?.id,
@@ -2775,15 +2764,6 @@ internal fun HomeScreen(
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
// 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.
@@ -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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
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.text.TextStyle
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.sp
import androidx.tv.material3.Text
@@ -51,13 +53,18 @@ internal fun PinBoxesField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
onDone: () -> Unit = {},
) {
BasicTextField(
value = value,
onValueChange = { onValueChange(it.filter(Char::isDigit).take(PIN_LENGTH)) },
textStyle = TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onDone() }),
modifier = modifier,
decorationBox = {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -51,7 +51,18 @@ internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
modifier = Modifier.width(460.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 {
Spacer(Modifier.height(10.dp))
Text(it, color = MembyAccent, fontSize = 13.sp)
@@ -60,15 +71,7 @@ internal fun PinSetupScreen(viewer: MembyViewer, onComplete: () -> Unit) {
Button(
enabled = !saving && pin.length == PIN_LENGTH,
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 8.dp),
onClick = {
saving = true
scope.launch {
runCatching { ServiceLocator.repository.setViewerPIN(viewer, pin) }
.onSuccess { onComplete() }
.onFailure { error = "That PIN could not be saved." }
saving = false
}
},
onClick = submitPin,
) { 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)
if (profile.viewer.hasPin) {
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 {
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.
*
* 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
* 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
@@ -2718,7 +2721,7 @@ class PlayerActivity : ComponentActivity() {
playback.isCurrentMediaItemSeekable &&
playback.duration > 0L &&
playback.duration != C.TIME_UNSET &&
playerView?.isControllerFullyVisible != true &&
!transportVisible &&
!castPanelVisible.value &&
subtitleOverlay?.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,
// 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
_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 ->
@@ -463,6 +466,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
if (_state.value.genre == null && _state.value.query.trim() == term) {
_state.update { it.copy(isLoading = false) }
cache[term] = _state.value.results
viewModelScope.launch { repository.recordSearch(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.requests.RequestCard
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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
@@ -153,7 +153,11 @@ internal fun ViewerPinEntry(
Spacer(Modifier.height(8.dp))
Text("Use a 4-digit PIN to protect this profile.", color = MembyQuietText)
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) }
Spacer(Modifier.height(20.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