diff --git a/app/app-icon.png b/app/app-icon.png new file mode 100644 index 0000000..d498139 Binary files /dev/null and b/app/app-icon.png differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80db907..4b5392f 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.24" +val defaultVersionName = "0.3.25" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 92fa9af..8ade879 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,14 +64,14 @@ android:name="android.hardware.microphone" android:required="false" /> + - android:icon="@drawable/glossy_m" + android:icon="@drawable/app_icon" android:label="@string/app_name" - android:roundIcon="@drawable/glossy_m" + android:roundIcon="@drawable/app_icon" android:supportsRtl="true" android:usesCleartextTraffic="true" android:theme="@style/Theme.Memby"> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index 999a3aa..ea22aa1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -620,7 +620,21 @@ private fun DetailHero( // screensaver honoured it. The dark-logo fallback comes with it — a black title // treatment on this near-black scrim is an invisible heading. val repository = ServiceLocator.repository - val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) { + // Keyed on the parent-logo identity as well as the item's own tags, because an episode + // carries no Logo of its own: its title treatment is the series', named by + // ParentLogoItemId/ParentLogoImageTag. Those two arrive on a route object that a + // thinner one — a re-focus behind the overlay, a lightweight recommendation card — can + // briefly replace, and a memo that could not see them resolved "no logo" once and kept + // that answer for the life of the page. It is the same key the launcher's hero and its + // metadata panel already use; the three must agree or the page contradicts the row it + // was opened from. + val logoUrl = remember( + item.id, + item.imageTags, + item.parentLogoItemId, + item.parentLogoImageTag, + repository.showTitleLogo, + ) { if (repository.showTitleLogo) repository.logoUrl(item) else null } val logo = logoUrl.takeIf { !useTextTitleForLogo(it) } 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 a212a22..7d9d0f3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -676,8 +676,14 @@ internal fun HomeScreen( var timeout: TimeoutCancellationException? = null repeat(2) { try { + // The request, not the card. `playbackRequestForLaunch` + // above is what resolves an episode's title treatment from + // its series, and re-deriving the request from the item + // here threw that away — so a cold start (no resume + // position, which is every unwatched pick) opened the + // player with no logo while a resume opened with one. return@run withTimeout(PLAYBACK_SOURCE_RESOLUTION_TIMEOUT) { - repo.resolvePlayableForLaunch(item) + repo.resolvePlayableForLaunch(request) } } catch (error: TimeoutCancellationException) { timeout = error diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 7644bf4..a1e3c36 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -722,7 +722,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }?.let { return it } } return try { - repository.getItemDetailsUncached(itemId).also { details -> + withSeriesTitleLogo(repository.getItemDetailsUncached(itemId)).also { details -> synchronized(metadataCache) { metadataCache[itemId] = details } } } catch (cancelled: kotlinx.coroutines.CancellationException) { @@ -732,6 +732,33 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } + /** + * Completes an episode record that names no title treatment of its own. + * + * An episode has no Logo image; its treatment is the series', named by + * ParentLogoItemId/ParentLogoImageTag — and a route or a detail response that did not + * ask for the Logo image type carries neither. Resolving it here rather than at each + * reader is what makes the launcher's hero, the detail page, the player and the pause + * hero draw the same artwork: the record they all merge from already has the answer, so + * none of them has to work it out, and none of them can work it out differently. + * + * The series lookup is the repository's cached, single-flighted one, so a show already + * opened this session costs nothing, and a series with genuinely no logo leaves the + * record exactly as it was rather than being asked about again. + */ + private suspend fun withSeriesTitleLogo(details: BaseItem): BaseItem { + if (!details.isEpisode || details.hasTitleLogoMetadata) return details + val seriesId = details.seriesId?.takeIf(String::isNotBlank) ?: return details + val series = try { + repository.getItemDetails(seriesId) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + return details.withSeriesTitleLogo(series) + } + /** Must be called while synchronised on [metadataCache]. */ private fun acceptSeriesStatusRevision(revision: Long) { if (revision <= 0L || revision == seriesStatusRevision) return @@ -751,6 +778,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { try { runCatching { repository.getItemDetails(itemId) } .getOrNull() + ?.let { withSeriesTitleLogo(it) } ?.also { details -> synchronized(metadataCache) { if (seriesStatusRevision == revisionAtStart) { 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 1aef848..fe63f87 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 @@ -107,6 +107,7 @@ import com.ponzischeme89.memby.ui.components.TvSettingsMenuOption import com.ponzischeme89.memby.ui.components.showTvSettingsMenu import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu import com.ponzischeme89.memby.ui.randomWelcomeQuote +import com.ponzischeme89.memby.ui.warmTitleLogo import com.ponzischeme89.memby.ui.theme.AppFontFamily import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.applyMembyTypeface @@ -235,6 +236,9 @@ class PlayerActivity : ComponentActivity() { private var launchTraceCookie = NO_TRACE private var firstFrameTraceCookie = NO_TRACE private var logoUrl: String? = null + + /** The logo [warmPauseHeroLogo] has already fetched and judged, so a pause never re-asks. */ + private var warmedLogoUrl: String? = null private var loadingBackdropUrl: String? = null private var playbackSeriesName: String? = null private var playbackTitle = "" @@ -4458,7 +4462,27 @@ class PlayerActivity : ComponentActivity() { updatePauseHeroMetadata() } + /** + * Fetches and judges the title treatment before a pause can ask for it. + * + * The pause hero draws a logo only once it has been judged legible against its own + * near-black, and until that verdict lands it shows the plain-text name — so a set that + * came straight into playback without the launcher having warmed this logo showed the + * text and swapped, on a surface that appears for a moment and is looked at directly. + * The transport's own identity strip draws the same artwork with no such verdict, which + * is what made the two disagree. Warming here costs nothing on a logo already seen: the + * verdict is remembered for the URL and is process-wide, so the launcher's probe and + * this one are the same probe. + */ + private fun warmPauseHeroLogo() { + val url = logoUrl?.takeIf(String::isNotBlank) ?: return + if (url == warmedLogoUrl) return + warmedLogoUrl = url + lifecycleScope.launch { warmTitleLogo(url) } + } + private fun updatePauseHeroMetadata() { + warmPauseHeroLogo() pauseHeroMetadata.value = PauseMediaHeroMetadata( title = playbackTitle, seriesName = playbackSeriesName, diff --git a/app/src/main/res/drawable-nodpi/app_icon.png b/app/src/main/res/drawable-nodpi/app_icon.png new file mode 100644 index 0000000..4dc6843 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/app_icon.png differ diff --git a/app/src/main/res/drawable-nodpi/glossy_m.png b/app/src/main/res/drawable-nodpi/glossy_m.png deleted file mode 100644 index 2cb55c1..0000000 Binary files a/app/src/main/res/drawable-nodpi/glossy_m.png and /dev/null differ