0.3.24
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.23"
|
||||
val defaultVersionName = "0.3.24"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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,15 +630,19 @@ 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.
|
||||
// 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.playbackRequest(item)
|
||||
val request = repo.playbackRequestForLaunch(item)
|
||||
request to repo.readyPlayableForLaunch(request)
|
||||
}.getOrElse {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
return@playItem
|
||||
return@launch
|
||||
}
|
||||
val request = prepared.first
|
||||
val ready = prepared.second
|
||||
@@ -665,13 +666,11 @@ internal fun HomeScreen(
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
return@playItem
|
||||
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
|
||||
resolveJob = scope.launch {
|
||||
try {
|
||||
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 @@
|
||||
0.1.74
|
||||
0.1.75
|
||||
|
||||
Reference in New Issue
Block a user