diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4fe7bb..80db907 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/glossy-m.png b/app/glossy-m.png new file mode 100644 index 0000000..2cb55c1 Binary files /dev/null and b/app/glossy-m.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d474b00..92fa9af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,9 +68,10 @@ android:name=".MembyApp" android:allowBackup="true" android:banner="@drawable/app_banner" - android:icon="@drawable/memby_mark" + + 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"> diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 1bf343b..df27c39 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index f1eef15..a212a22 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -407,9 +407,6 @@ internal fun HomeScreen( val destinationFocus = remember { mutableMapOf>() } - 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, "Couldn’t 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, "Couldn’t 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, "Couldn’t 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, "Couldn’t 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( } } - - diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/PinInput.kt b/app/src/main/java/com/ponzischeme89/memby/ui/PinInput.kt index f72d5fc..23d4919 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/PinInput.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/PinInput.kt @@ -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)) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/PinSetupScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/PinSetupScreen.kt index aac563e..495b748 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/PinSetupScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/PinSetupScreen.kt @@ -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") } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RecoveryScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RecoveryScreen.kt index b5cf29a..4d070ea 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/RecoveryScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RecoveryScreen.kt @@ -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)) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index cba7f10..1aef848 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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 && diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index 19a56c8..a25d480 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt index 3b0850e..5b18ac3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt index 54b6ed7..df15c08 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/viewers/ViewerEditing.kt @@ -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)) { diff --git a/app/src/main/res/drawable-nodpi/glossy_m.png b/app/src/main/res/drawable-nodpi/glossy_m.png new file mode 100644 index 0000000..2cb55c1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/glossy_m.png differ diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 21f074d..d9ba747 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.74 +0.1.75