diff --git a/CHANGELOG.md b/CHANGELOG.md index bda11ca..e199200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ +## 0.2.38 — 2026-08-09 +- Improved: Ratings are larger and easier to read. +- Improved: Genre cards now use the same poster format as home-screen rows. +- Improved: The Memby rail logo now animates when opening every section. +- Improved: The user switcher now shows each profile's initials. +- Added: The About screen now shows the connected Emby gateway version. +- Changed: The genre browser is now controlled by the server and is off by default while its layout is refined. +- Fixed: The automatic home-screen artwork setting no longer reverts to posters. +- Fixed: Watched changes now appear on posters immediately, just like favourite changes. + ## 0.2.37 — 2026-08-09 - Improved: Browse films and TV shows from colourful genre cards with clearer, consistent categories. - Improved: Genre pages now keep their place and show Favourite changes immediately after returning from a title. +- Improved: App updates now show a brief confirmation instead of a full-screen release-notes panel. +- Fixed: Episodes without their own cast information now use the series cast in the player. ## 0.2.36 — 2026-08-09 - Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging. diff --git a/CLAUDE.md b/CLAUDE.md index d20fb1e..84975cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,22 +137,15 @@ About as one collapsible release per entry. So a release edits one file and the stays readable offline. Keep the `## ` / `- bullet` shape; anything else in the file is skipped as prose, and a bullet wrapped onto a second line is rejoined. -**The same file is what a TV shows after it updates itself.** `ui/whatsnew/` puts the -running build's changelog entry over the launcher once, and `Settings.whatsNewSeenVersion` -(device state, deliberately not a synced preference — what is new is a property of the APK -on *this* set) records that it has been. `whatsNewDecision` is the pure rule and holds the -three cases that must not show a panel: a version already recorded, a **fresh install** -(no record and nobody signed in — everything is new to that TV, so it is marked seen during -setup instead), and a build the changelog does not describe, which is marked seen rather -than shown as an empty panel. Signed out *with* a record is neither: the notes belong over -the launcher, so that launch waits. Things to preserve — it is an overlay composed after -`HomeScreen`, not a branch of `AppRoot`'s `when`, so the cached rows are already drawn -behind it and nothing about it can delay startup; the version is recorded on dismissal, -so a set switched off mid-panel is told again rather than never; and the Continue button -points every direction back at itself, or one press of Down walks into rows the viewer -cannot see behind the scrim. `maxChangesFor` derives the bullet count from the screen for -the same reason detail panes derive their height — a panel that overruns a 720p set has no -scrollbar to aim at and hides its own button. +**An update is acknowledged once with a toast.** `ui/whatsnew/whatsNewDecision` compares the +running build with `Settings.whatsNewSeenVersion`, and `AppRoot` briefly says “Memby has been +updated to version …” over the launcher before recording it. The record is device state, +deliberately not a synced preference: what is new is a property of the APK on *this* set. +Two quiet cases matter: a **fresh install** has not updated from anything, so setup records +the current build without announcing it; and a signed-out set with an older record waits +until somebody signs in, because the notice belongs over the launcher. The changelog is no +longer part of this decision — release history remains available in Settings → About, while +local or unreleased builds still receive the same one-time update acknowledgement. **Forced updates are server-controlled.** `server/internal/appupdate` decides `none` / `optional` / `mandatory` from the client's `X-Memby-Version` header against an diff --git a/README.md b/README.md index f71cece..6d15757 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,70 @@ # Memby (Android TV) -An independent Android TV client for Emby, by **ponzischeme89**. Sign in to browse your -personalised home screen, resume titles, play recent movies, and choose audio or subtitle -tracks during playback. The screensaver (Dream) code remains isolated from the client work. +An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a +personalised television launcher, full media player, system screensaver and an optional +self-hosted gateway that shapes the experience for every viewer in the household. Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby) ## Features -- **System screensaver** via `DreamService` — auto-starts on idle once selected in the - TV's Screensaver settings. Also previewable from the app's home screen. -- Random, cross-fading **backdrops** of Movies and Series that have backdrop images. -- **Interactive**: OK reveals Play / Favorite actions; ◄ ► change the backdrop; Back exits. -- **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its - next-up / first episode). -- **Favorites** managed through the Emby API and shown on the home screen. -- A dedicated **For You** destination, shaped by Tracearr completion/device history, - Memby browsing signals and a viewer-selected 30/60/120-minute time window. Every pick - says why it was chosen and whether it has direct-play evidence on that television. +### Browse and discover + +- Personalised, server-composed Home rows with a cached instant-start launcher. +- A cinematic featured hero that rotates through ranked films and series premieres. +- Continue Watching merged with Next Up, ordered by the viewer's recent activity. +- Favourites, latest films, recommendations, curated shelves and schedule rows. +- Dedicated Movies, TV Shows, Favourites and For You destinations. +- Instant search with an on-screen keyboard, voice input and session result caching. +- Optional, server-controlled genre browsing with paged film and series catalogues. +- A dedicated For You page combining viewing history, browsing signals, available time + and direct-play evidence; every recommendation explains why it was selected. +- More Like This, cast and technical details on film and series pages. +- Per-title IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb ratings where available. +- My Shows tracking, upcoming episodes, lifecycle badges and personal return alerts. +- Themes, seasonal colour schemes and optional launcher decorations. + +### Playback + +- Media3/ExoPlayer playback with direct play, compatible-stream remuxing and HLS fallback. +- Resume playback, progress reporting, watched state and immediate poster badge updates. +- Audio and subtitle track selection with per-viewer language preferences. +- Missing-subtitle search and download through Bazarr or OpenSubtitles when configured. +- Configurable Left/Right seeking with trickplay thumbnail previews. +- Skip-intro prompts or automatic skipping from Emby's chapter markers. +- Automatic next-episode playback with an on-screen countdown. +- Closing-credits presentation with the next episode alongside and adaptive 2× playback. +- Episode pages with season navigation, watched markers and estimated completion dates. +- In-player cast lookup, pause artwork, time-remaining reminders and service-status banners. + +### Profiles, televisions and administration + +- Multiple saved viewer profiles with quick switching from the navigation rail. +- Per-viewer settings synchronised through the gateway across every signed-in television. +- Device management, session revocation and stable television identity. +- Gateway-controlled feature flags, maintenance mode, forced app updates and live themes. +- Service alerts for new library items, aired episodes, deployments and Emby outages. +- A dependency-free web console for accounts, devices, settings history, features, + updates, library imports, recommendations, searches, row engagement and logs. +- Postgres catalogue import, Redis caching, full-text search and background recommendation + and ratings warming. + +### Screensaver and updates + +- A system `DreamService` screensaver with cross-fading library backdrops. +- Interactive screensaver controls for playback, favourites and artwork navigation. +- An in-app screensaver preview using the same rendering surface as the system Dream. +- Self-hosted signed APK updates with SHA-256, package and signing-key verification. +- Mandatory and optional update policy controlled by the gateway. ## Tech stack -- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material`) +- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`) - **Media3 / ExoPlayer** for playback - Retrofit + OkHttp + kotlinx.serialization for the Emby REST API -- DataStore for persisted connection/session +- DataStore for persisted profiles, settings and cached launcher state - Coil for backdrop image loading +- Go gateway with Postgres and Redis ## Project layout @@ -36,7 +75,7 @@ app/src/main/java/com/ponzischeme89/memby/ data/ SettingsStore.kt DataStore-backed connection/session state ServerConfig.kt Which backend this build talks to - EmbyRepository.kt Content, favorites, playback; dual gateway/direct paths + EmbyRepository.kt Content, favourites, playback; dual gateway/direct paths analytics/RowAnalytics.kt Row engagement buffering model/ Emby + gateway DTOs (kotlinx.serialization) remote/EmbyApi.kt Retrofit interface for Emby @@ -86,7 +125,7 @@ and the TV holds a revocable gateway token rather than a live Emby token. ``` direct: TV ──────────────────────────────► Emby gateway: TV ──► Memby gateway ──► Emby (metadata + artwork) - TV ─────────────────────► Emby (video, always direct-play) + TV ─────────────────────► Emby (video stream; never proxied by the gateway) ``` Which one a build uses is decided by `memby.gatewayUrl` in `gradle.properties`: set it and @@ -186,13 +225,11 @@ decides what the app offers, so rolling back is editing one file. ### How TVs update themselves -In Memby's Settings, set the update URL to `https://nas.example.com/memby/latest.json`. -**Check for updates** then downloads and installs on the TV, no computer involved. A URL -ending in `.json` is read as a static manifest; anything else is treated as a Gitea host -(`/api/v1/repos/{owner}/{repo}/releases/latest`), so either source works. - -The manifest's `apkUrl` may be relative (`memby-0.1.54.apk`) and is resolved against the -manifest's own URL, so the folder keeps working if the NAS is reached by another name. +The gateway checks the running app version on launch and returns an optional or mandatory +update with its download address. Memby downloads the APK, verifies its checksum, package +and release signature, then commits it through Android's package installer. Update policy +and the download address are managed in the gateway's admin console; viewers do not need +to configure an update source on the television. First install on each TV still has to be manual — the **Downloader** app pointed at the landing page is the usual route, and the page explains it. @@ -233,4 +270,3 @@ Playback capability probing and device-profile generation contain GPLv2 adaptati [Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from [Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv). Attribution and modification notices are preserved in the adapted source files. -``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c752f8..b73dbc5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.37" +val defaultVersionName = "0.2.38" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt b/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt new file mode 100644 index 0000000..9da7a09 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt @@ -0,0 +1,24 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.EmbyPerson + +/** + * Loads the cast Emby associates with an item. + * + * Episodes commonly omit `People`, while their parent series holds the canonical cast. + * Keep episode-specific credits when Emby supplies them, and only pay for the series + * request when the episode has no cast of its own. + */ +internal suspend fun resolveCast( + itemId: String, + loadItem: suspend (String) -> BaseItem, +): List { + val item = loadItem(itemId) + if (item.cast.isNotEmpty()) return item.cast + + val seriesId = item.seriesId + ?.takeIf(String::isNotBlank) + ?.takeUnless { it == item.id } + return if (item.isEpisode && seriesId != null) loadItem(seriesId).cast else emptyList() +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index 07e2b33..5d8cbb5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -91,6 +91,8 @@ class MaintenanceMonitor( private val _preferencesRevision = MutableStateFlow(0L) private val _theme = MutableStateFlow(GatewayThemeStatus()) private val _installPermissionPrompt = MutableStateFlow(false) + private val _genreBrowserEnabled = MutableStateFlow(false) + private val _gatewayVersion = MutableStateFlow("") /** * Which colour scheme this viewer's televisions should be painted, as an id and a @@ -118,6 +120,12 @@ class MaintenanceMonitor( */ val installPermissionPrompt: StateFlow = _installPermissionPrompt.asStateFlow() + /** Server-controlled because the browser layout is still being refined. */ + val genreBrowserEnabled: StateFlow = _genreBrowserEnabled.asStateFlow() + + /** Build reported by the connected gateway, for Settings → About. */ + val gatewayVersion: StateFlow = _gatewayVersion.asStateFlow() + private val seenAlertIds = mutableSetOf() private var seenAlertsLoaded = false private var shownAlertId: String? = null @@ -173,6 +181,8 @@ class MaintenanceMonitor( _preferencesRevision.value = 0 _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false + _genreBrowserEnabled.value = false + _gatewayVersion.value = "" dismissAlert() return@collectLatest } @@ -202,6 +212,8 @@ class MaintenanceMonitor( _theme.value = status.theme _installPermissionPrompt.value = status.features[INSTALL_PERMISSION_FEATURE] == true + _genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true + _gatewayVersion.value = status.gatewayVersion // Emby's state is reported even during maintenance: an // operator taking Memby down while Emby is also unreachable // should not have that fact disappear from the poll. @@ -223,6 +235,8 @@ class MaintenanceMonitor( _preferencesRevision.value = 0 _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false + _genreBrowserEnabled.value = false + _gatewayVersion.value = "" dismissAlert() return@collectLatest } @@ -323,6 +337,9 @@ class MaintenanceMonitor( /** Matches `featureInstallPermission` in the gateway's feature catalogue. */ internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt" + /** Matches `featureGenreBrowser` in the gateway's feature catalogue. */ + internal const val GENRE_BROWSER_FEATURE = "genre_browser" + /** * How often Emby is retried during an outage. It matches the gateway's own * default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the diff --git a/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt b/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt index bbd3750..4f96cf9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/PreferencesSync.kt @@ -148,8 +148,17 @@ class PreferencesSync( if (decoded == trigger.local) { runCatching { settings.setPreferencesRevision(stored.revision) } } else { - runCatching { settings.applyRemotePreferences(decoded, stored.revision) } - .onFailure { lastSynced = null } + // The request may have been in flight while the viewer made a newer choice. + // A conflict describes the document that won against the request, not against + // an edit made after it. Advance that newer edit to the winning revision and + // let the queued trigger push it, instead of replacing it with stale values. + val current = runCatching { settings.snapshot().toUserPreferences() }.getOrNull() + if (current != null && current != trigger.local) { + runCatching { settings.setPreferencesRevision(stored.revision) } + } else { + runCatching { settings.applyRemotePreferences(decoded, stored.revision) } + .onFailure { lastSynced = null } + } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index f5fc076..df2ac61 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -376,10 +376,10 @@ data class Settings( */ val onboardedUserIds: Set = emptySet(), /** - * The app version whose release notes this television has already shown. Deliberately - * device state rather than a synced preference: what is new is a property of the APK - * sitting on this set, and a viewer who signs into a second TV that is still a version - * behind has not seen that build's notes. + * The app version this television has already announced. Deliberately device state + * rather than a synced preference: what is new is a property of the APK sitting on + * this set, and a viewer who signs into a second TV that is still a version behind has + * not seen that build's update notice. */ val whatsNewSeenVersion: String? = null, /** @@ -968,10 +968,9 @@ class SettingsStore(private val context: Context) { } /** - * Records the version whose release notes have been shown on this television, so the - * "what's new" panel appears exactly once per update. Written on dismissal, and also - * written silently on a fresh install so the first sign-in is not greeted by notes for - * a build the viewer has never been without. + * Records the version announced on this television, so the update toast appears exactly + * once. Also written silently on a fresh install, which has not updated from an earlier + * build and therefore has nothing to announce. */ suspend fun markWhatsNewSeen(version: String) { val trimmed = version.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index a011aee..edbb2b9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -119,6 +119,7 @@ data class GatewayServiceStatus( val compatibilityMessage: String = "", val clientVersion: String = "", val clientProtocol: String = "", + val gatewayVersion: String = "", val serverProtocol: Int = 0, val featureSchemaVersion: Int = 0, val featureRevision: Long = 0, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 5038c46..53169b1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -74,6 +74,7 @@ internal val MEMBY_CAPABILITIES = listOf( "live_feature_refresh_v1", "sonarr_preroll_v1", "auto_my_shows_v1", + "genre_browser_v1", // Declares that this build can show the install-permission step. An older app never // receives the feature, so the operator cannot push a screen it does not have. "install_permission_v1", diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 9b91ca7..a48889f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -234,6 +234,7 @@ fun TvNavigationRail( onDestinationSelected: (BrowseDestination) -> Unit, modifier: Modifier = Modifier, alertCount: Int = 0, + activeUsername: String = "", ) { var railHasFocus by remember { mutableStateOf(false) } val logoScale = remember { Animatable(0.72f) } @@ -246,10 +247,8 @@ fun TvNavigationRail( } } LaunchedEffect(selected) { - if (selected == BrowseDestination.HOME) { - logoRotation.snapTo(0f) - logoRotation.animateTo(360f, tween(850)) - } + logoRotation.snapTo(0f) + logoRotation.animateTo(360f, tween(850)) } LaunchedEffect(railHasFocus) { if (railHasFocus) { @@ -355,6 +354,9 @@ fun TvNavigationRail( } else { null }, + avatarInitials = activeUsername.takeIf { + destination == BrowseDestination.PROFILES + }?.let(::profileInitials), ) Spacer(Modifier.height(4.dp)) } @@ -567,7 +569,7 @@ private fun UserSwitcherProfileItem( contentAlignment = Alignment.Center, ) { Text( - profile.username.trim().firstOrNull()?.uppercase() ?: "?", + profileInitials(profile.username), color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, @@ -656,6 +658,7 @@ fun ExpandableNavigationItem( onClick: () -> Unit, modifier: Modifier = Modifier, badge: String? = null, + avatarInitials: String? = null, ) { var focused by remember { mutableStateOf(false) } // Not `by`: both colours are read in the draw phase / at the point of use, so the @@ -698,7 +701,26 @@ fun ExpandableNavigationItem( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) { - Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp)) + if (avatarInitials != null) { + Box( + Modifier + .size(25.dp) + .background( + if (selected) EmbyGreen else foreground.value.copy(alpha = 0.22f), + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + avatarInitials, + color = if (selected) Color.White else foreground.value, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + ) + } + } else { + Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp)) + } if (selected) { Box( Modifier @@ -735,6 +757,22 @@ fun ExpandableNavigationItem( } } +/** Two readable initials for compact profile avatars: AlwynV → AV, Matt Cohen → MC. */ +internal fun profileInitials(username: String): String { + val trimmed = username.trim() + if (trimmed.isEmpty()) return "?" + val words = trimmed.split(Regex("\\s+")).filter(String::isNotBlank) + if (words.size > 1) { + return "${words.first().first()}${words.last().first()}".uppercase() + } + val capitals = trimmed.filter(Char::isUpperCase) + return when { + capitals.length >= 2 -> capitals.take(2).uppercase() + trimmed.length >= 2 -> trimmed.take(2).uppercase() + else -> trimmed.uppercase() + } +} + @Composable fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { val context = LocalContext.current 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 154f8f4..4fb2a5e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -150,6 +150,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { // when the server rejects it, without refreshing or losing the active category. private val _favoriteChanges = MutableStateFlow>(emptyMap()) val favoriteChanges: StateFlow> = _favoriteChanges.asStateFlow() + private val _playedChanges = MutableStateFlow>(emptyMap()) + val playedChanges: StateFlow> = _playedChanges.asStateFlow() private val _forYou = MutableStateFlow(ForYouUiState()) val forYou: StateFlow = _forYou.asStateFlow() private var metadataJob: Job? = null @@ -417,6 +419,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } fun setPlayed(item: BaseItem, played: Boolean) { + val previousPlayed = item.userData?.played == true + val previousPosition = item.userData?.playbackPositionTicks ?: 0L + _playedChanges.update { it + (item.id to played) } updateUserData(item.id) { it.copy( played = played, @@ -426,10 +431,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { viewModelScope.launch(Dispatchers.IO) { runCatching { repository.setPlayed(item.id, played) } .onSuccess { confirmed -> - updateUserData(item.id) { it.copy(played = confirmed) } + _playedChanges.update { it + (item.id to confirmed) } + updateUserData(item.id) { + it.copy( + played = confirmed, + playbackPositionTicks = if (confirmed) 0L else it.playbackPositionTicks, + ) + } } .onFailure { - updateUserData(item.id) { it.copy(played = !played) } + _playedChanges.update { it + (item.id to previousPlayed) } + updateUserData(item.id) { + it.copy(played = previousPlayed, playbackPositionTicks = previousPosition) + } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index ada8887..b4f5681 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -138,12 +138,9 @@ import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.performance.PerformanceMonitor import com.ponzischeme89.memby.ui.search.SearchScreen -import com.ponzischeme89.memby.ui.settings.MembyReleaseHistory -import com.ponzischeme89.memby.ui.settings.ReleaseNote import com.ponzischeme89.memby.ui.settings.SettingsSheet import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations -import com.ponzischeme89.memby.ui.whatsnew.WhatsNewOverlay import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyChipCorner @@ -228,7 +225,7 @@ private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L @Composable private fun AppRoot(onCloseSettings: () -> Unit) { val repo = ServiceLocator.repository - val scope = rememberCoroutineScope() + val context = LocalContext.current // This client intentionally has no token provider and no dependency on the active // profile. Updates are an app lifecycle concern, checked before login/session work. val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } @@ -257,9 +254,6 @@ private fun AppRoot(onCloseSettings: () -> Unit) { ) } var onboardingToken by remember { mutableStateOf(null) } - // The release notes for the build that has just been installed, or null. Resolved from - // disk only — see whatsNewDecision — so it can never delay the launcher. - var whatsNew by remember { mutableStateOf(null) } LaunchedEffect(updateService) { var firstCheck = true while (true) { @@ -346,24 +340,28 @@ private fun AppRoot(onCloseSettings: () -> Unit) { recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true) } - // Whether this TV has already been told what changed in the build it is running. Keyed + // Whether this TV has already been told about the build it is running. Keyed // on the recorded version and the session, because a fresh install records the current - // version during setup and a sign-in is what turns "waiting" into "show it now". + // version during setup and a sign-in is what turns "waiting" into "announce it now". LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) { val loaded = settings ?: return@LaunchedEffect val decision = whatsNewDecision( installedVersion = BuildConfig.VERSION_NAME, seenVersion = loaded.whatsNewSeenVersion, isSignedIn = loaded.isSignedIn, - releases = MembyReleaseHistory, ) when (decision) { - is WhatsNewDecision.Show -> whatsNew = decision.release - // Recorded without being shown, which is also what retires the panel after the - // viewer dismisses it: the settings flow re-emits and this lands on Nothing. + is WhatsNewDecision.Notify -> { + Toast.makeText( + context, + context.getString(R.string.app_updated_to_version, decision.version), + Toast.LENGTH_LONG, + ).show() + ServiceLocator.settings.markWhatsNewSeen(decision.version) + } is WhatsNewDecision.MarkSeen -> ServiceLocator.settings.markWhatsNewSeen(decision.version) - WhatsNewDecision.Nothing -> whatsNew = null + WhatsNewDecision.Nothing -> Unit } } @@ -431,20 +429,6 @@ private fun AppRoot(onCloseSettings: () -> Unit) { decoration = ServiceLocator.themeSync.theme .collectAsState().value?.decoration.orEmpty(), ) - // Over the launcher, not instead of it: the cached rows are already drawn - // behind this. Composed after HomeScreen so its Back handler and its focus - // request are the ones that win. - whatsNew?.let { release -> - WhatsNewOverlay( - release = release, - onDismiss = { - whatsNew = null - scope.launch { - ServiceLocator.settings.markWhatsNewSeen(release.version) - } - }, - ) - } } loaded.profiles.isNotEmpty() -> ProfileEntryScreen( settings = loaded, @@ -1355,8 +1339,10 @@ private fun HomeScreen( val homeStatus by homeViewModel.status.collectAsStateWithLifecycle() val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle() val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle() + val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() + val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle() // Only whether there is one, not its countdown — that is collected inside the banner // so a ticking second never reaches the launcher. This is read here purely to decide // which of the two top bars gets the strip. @@ -1374,6 +1360,12 @@ private fun HomeScreen( var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var genreBrowseItemType by remember { mutableStateOf(null) } var genreBrowseInitialCategoryId by remember { mutableStateOf(null) } + LaunchedEffect(genreBrowserEnabled) { + if (!genreBrowserEnabled) { + genreBrowseItemType = null + genreBrowseInitialCategoryId = null + } + } var navigationExpanded by rememberSaveable { mutableStateOf(false) } var restoreRailAfterSettings by remember { mutableStateOf(false) } var detailsItem by remember { mutableStateOf(null) } @@ -1699,6 +1691,7 @@ private fun HomeScreen( } }, alertCount = notificationState.notifications.size, + activeUsername = settings.username.orEmpty(), ) androidx.compose.foundation.layout.BoxWithConstraints( modifier = Modifier @@ -1773,6 +1766,7 @@ private fun HomeScreen( initialCategoryId = genreBrowseInitialCategoryId ?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID, favouriteStates = favoriteChanges, + playedStates = playedChanges, navigationFocusRequester = navigationFocusRequester, contentFocusRequester = contentFocusRequester, returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID }, @@ -1930,8 +1924,9 @@ private fun HomeScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { if ( - selectedDestination == BrowseDestination.MOVIES || - selectedDestination == BrowseDestination.SHOWS + genreBrowserEnabled && + (selectedDestination == BrowseDestination.MOVIES || + selectedDestination == BrowseDestination.SHOWS) ) { item(key = "genre-browser", contentType = "genre-browser") { val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie" @@ -1955,9 +1950,12 @@ private fun HomeScreen( availableWidth = contentWidth, density = settings.homeCardDensity, navigationFocusRequester = navigationFocusRequester, - // The Genres launcher above is the destination's entry - // target. One requester cannot be attached to both. - contentFocusRequester = null, + // The genre launcher owns the entry target only while + // the server has enabled it. When it is off, My Shows + // becomes the first reachable content on this page. + contentFocusRequester = contentFocusRequester.takeUnless { + genreBrowserEnabled + }, onShowSelected = { selectedMyShow = it }, onContentFocused = { navigationExpanded = false }, ) @@ -2015,8 +2013,10 @@ private fun HomeScreen( navigationFocusRequester = navigationFocusRequester, contentEntryFocusRequester = contentFocusRequester.takeIf { !hasHomeHero && - selectedDestination != BrowseDestination.MOVIES && - selectedDestination != BrowseDestination.SHOWS && + (selectedDestination != BrowseDestination.SHOWS || + (!genreBrowserEnabled && myShows.isEmpty())) && + (selectedDestination != BrowseDestination.MOVIES || + !genreBrowserEnabled) && row.id == firstPopulatedRowId }, heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt index 1a09401..f243e4c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RatingsStrip.kt @@ -45,9 +45,9 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyRatingsSurface import kotlin.math.roundToInt -private val RatingStripHeight = 27.dp -private val RatingMarkHeight = 15.dp -private val RatingMarkHeightCompact = 13.dp +private val RatingStripHeight = 34.dp +private val RatingMarkHeight = 19.dp +private val RatingMarkHeightCompact = 16.dp /** One non-focusable, layout-stable presentation for ratings everywhere in the app. */ @Composable @@ -73,13 +73,13 @@ fun RatingsStrip( contentAlignment = Alignment.CenterStart, ) { // The responsive limit is based on the usable width inside the capsule. - val contentWidth = (maxWidth.value.roundToInt() - 20).coerceAtLeast(0) + val contentWidth = (maxWidth.value.roundToInt() - 24).coerceAtLeast(0) val shown = valid.take(ratingDisplayLimit(contentWidth)) Row( modifier = Modifier .clip(RoundedCornerShape(percent = 50)) .background(MembyRatingsSurface) - .padding(horizontal = 10.dp, vertical = 4.dp), + .padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 13.dp), ) { @@ -92,7 +92,7 @@ fun RatingsStrip( Text( rating.formattedScore(), color = MembyOnSurface, - fontSize = if (compact) 11.sp else 13.sp, + fontSize = if (compact) 13.sp else 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, ) @@ -160,7 +160,7 @@ private fun RatingMark(rating: MediaRating, compact: Boolean) { Text( rating.wordmark(), color = providerColor(rating.source), - fontSize = if (compact) 10.sp else 12.sp, + fontSize = if (compact) 12.sp else 14.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Clip, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 8d1014c..741bec9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -71,6 +72,7 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.PosterGridCard import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised @@ -124,35 +126,36 @@ private fun GenreDiscoveryCard( onFocused = onFocused, onClick = onClick, contentDescription = "Browse ${category.label}", - modifier = modifier - .width(184.dp) - .height(92.dp) - .background(visual.colour, RoundedCornerShape(12.dp)) - .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(12.dp)), + modifier = modifier.width(142.dp), ) { focused -> - Box( - Modifier - .fillMaxSize() - .background( - if (focused) Color.White.copy(alpha = 0.16f) else Color.Transparent, - RoundedCornerShape(12.dp), + Column { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .background(visual.colour, RoundedCornerShape(MembyCardCorner)) + .border( + 2.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.07f), + RoundedCornerShape(MembyCardCorner), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + visual.icon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(48.dp), ) - .padding(14.dp), - ) { + } + Spacer(Modifier.height(8.dp)) Text( category.label, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.Bold, - lineHeight = 19.sp, - maxLines = 2, - modifier = Modifier.align(Alignment.BottomStart).width(120.dp), - ) - Icon( - visual.icon, - contentDescription = null, - tint = Color.White.copy(alpha = 0.92f), - modifier = Modifier.align(Alignment.TopEnd).size(32.dp), + color = if (focused) Color.White else MembyMutedText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -185,6 +188,7 @@ fun GenreBrowseScreen( itemType: String, initialCategoryId: String, favouriteStates: Map, + playedStates: Map, navigationFocusRequester: FocusRequester, contentFocusRequester: FocusRequester, returnFocusItemId: String?, @@ -210,6 +214,7 @@ fun GenreBrowseScreen( LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) } LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) } + LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) } LaunchedEffect(state.selectedCategoryId) { val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0) if (state.selectedCategoryId != null) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 331dcd4..075aa9d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -100,6 +100,28 @@ class GenreBrowseViewModel( } } + /** Applies optimistic watched changes to the paged copies without losing position. */ + fun applyPlayedStates(changes: Map) { + if (changes.isEmpty()) return + categoryPages.entries.forEach { entry -> + entry.setValue( + entry.value.copy( + items = entry.value.items.map { item -> + changes[item.id]?.let { item.withPlayed(it) } ?: item + }, + ), + ) + } + _state.update { current -> + current.copy( + items = current.items.map { item -> + val played = changes[item.id] ?: return@map item + item.withPlayed(played) + }, + ) + } + } + private suspend fun loadPage(category: GenreCategory, offset: Int) { runCatching { if (category.genres.isEmpty()) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt index ddb3889..f844288 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt @@ -102,3 +102,10 @@ fun allMediaLabel(itemType: String): String = fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy( userData = (userData ?: UserItemData()).copy(isFavorite = favourite), ) + +fun BaseItem.withPlayed(played: Boolean): BaseItem = copy( + userData = (userData ?: UserItemData()).copy( + played = played, + playbackPositionTicks = if (played) 0L else userData?.playbackPositionTicks ?: 0L, + ), +) 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 82c5531..5f9cd63 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 @@ -73,6 +73,7 @@ import com.ponzischeme89.memby.data.SUBTITLE_LANGUAGE_AUTO import com.ponzischeme89.memby.data.SubtitleCandidate import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds import com.ponzischeme89.memby.data.normalizeSkipIntroMode +import com.ponzischeme89.memby.data.resolveCast import com.ponzischeme89.memby.data.selectSubtitleId import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.GatewayPrerollEntry @@ -2972,7 +2973,8 @@ class PlayerActivity : ComponentActivity() { } castJob = lifecycleScope.launch { val loaded = runCatching { - ServiceLocator.repository.getItemDetails(requestedItemId).cast.take(MAX_CAST_MEMBERS) + resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails) + .take(MAX_CAST_MEMBERS) }.getOrDefault(emptyList()) if (itemId == requestedItemId) { castPeople = loaded diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 7858e53..711dd22 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -229,6 +229,7 @@ internal data class SettingsPanelState( val themeNotice: String = "", val selectedPage: SettingsPage = SettingsPage.APPEARANCE, val installedVersion: String = "", + val gatewayVersion: String = "", val releaseHistory: List = MembyReleaseHistory, val devices: List = emptyList(), val devicesLoading: Boolean = false, @@ -317,6 +318,7 @@ fun SettingsSheet( // stays parameter-driven and screenshot-testable with no server. val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState() val availableThemes by ServiceLocator.themeSync.available.collectAsState() + val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsState() var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) } var devices by remember { mutableStateOf>(emptyList()) } var devicesLoading by remember { mutableStateOf(false) } @@ -430,6 +432,7 @@ fun SettingsSheet( themeNotice = resolvedTheme?.takeIf { it.locked }?.reason.orEmpty(), selectedPage = selectedPage, installedVersion = checker.installedVersion, + gatewayVersion = gatewayVersion, devices = devices, devicesLoading = devicesLoading, devicesError = devicesError, @@ -938,6 +941,11 @@ internal fun SettingsPanelContent( fontWeight = FontWeight.Bold, ) } + SettingDivider() + VersionRow( + label = "Memby gateway", + version = state.gatewayVersion.takeIf(String::isNotBlank) ?: "Not connected", + ) } } if (state.selectedPage == SettingsPage.ABOUT) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt index 940ee09..843f492 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt @@ -6,12 +6,10 @@ package com.ponzischeme89.memby.ui.whatsnew -import com.ponzischeme89.memby.ui.settings.ReleaseNote - -/** What a launch should do about the release notes for the build that is running. */ +/** What a launch should do about the update notice for the build that is running. */ internal sealed interface WhatsNewDecision { - /** Show these notes, then record the version once the viewer dismisses them. */ - data class Show(val release: ReleaseNote) : WhatsNewDecision + /** Briefly announce this installed version, then record it. */ + data class Notify(val version: String) : WhatsNewDecision /** Record the version without showing anything. */ data class MarkSeen(val version: String) : WhatsNewDecision @@ -21,19 +19,18 @@ internal sealed interface WhatsNewDecision { } /** - * Decides whether a television that has just been updated should be told what changed. + * Decides whether a television that has just been updated should announce the new version. * - * Pure, because the interesting part is not the panel but which launches it must stay out - * of. Three cases are deliberately not "show": + * Pure, because the interesting part is which launches the toast must stay out of. Three + * cases are deliberately not "notify": * * - **Already recorded.** The whole contract is once per update; every later launch of the * same build is silent, which is what [seenVersion] exists for. - * - **A fresh install** (no record at all, and nobody signed in yet). Everything in this - * build is new to that TV, so a list of changes since a version it never ran is noise at - * the worst moment. It is marked seen during setup so the first launcher is clean. - * - **A build the changelog does not describe.** An unreleased or locally-built version has - * no entry, and an empty panel is worse than none — it is marked seen instead, so the - * next real update still lands. + * - **A fresh install** (no record at all, and nobody signed in yet). It has not updated + * from an earlier version, so it is marked seen during setup and the first launcher stays + * quiet. + * - **Signed out.** A toast belongs over the launcher, so an updated television with an + * existing record waits until somebody signs in rather than consuming the notice early. * * Signed out with a record already present is [Nothing] rather than [MarkSeen]: the notes * belong over the launcher, so that launch simply waits for whoever is about to sign in. @@ -42,19 +39,11 @@ internal fun whatsNewDecision( installedVersion: String, seenVersion: String?, isSignedIn: Boolean, - releases: List, ): WhatsNewDecision { val version = installedVersion.trim() if (version.isEmpty()) return WhatsNewDecision.Nothing if (seenVersion?.trim() == version) return WhatsNewDecision.Nothing if (seenVersion == null && !isSignedIn) return WhatsNewDecision.MarkSeen(version) if (!isSignedIn) return WhatsNewDecision.Nothing - val release = releases.firstOrNull { it.version == version && it.changes.isNotEmpty() } - ?: return WhatsNewDecision.MarkSeen(version) - return WhatsNewDecision.Show(release) + return WhatsNewDecision.Notify(version) } - -// A bullet's leading "Fixed:" / "Added:" used to be split off into an accent pill. Both -// places a viewer reads release notes — this panel and Settings → About — now render the -// line the changelog was written with, so there is nothing left to parse. See -// WhatsNewOverlay's ChangeRow for why. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt deleted file mode 100644 index c51b3b5..0000000 --- a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright (C) 2026 Memby contributors - * - * SPDX-License-Identifier: GPL-2.0-only - */ - -package com.ponzischeme89.memby.ui.whatsnew - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import androidx.tv.material3.Text -import com.ponzischeme89.memby.ui.UpdateButton -import com.ponzischeme89.memby.ui.settings.ReleaseNote -import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyMutedText -import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyPanelCorner -import com.ponzischeme89.memby.ui.theme.MembyQuietText -import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised -import kotlinx.coroutines.delay - -/** - * The most bullets the panel will print even on a tall screen. A release with more is not - * losing anything — the full history is in Settings → About, which the closing line points - * at — but past half a dozen the panel stops being a note and becomes a document. - */ -private const val MAX_CHANGES = 6 - -/** Everything above and below the bullets: eyebrow, version, date, button and padding. */ -private const val CHROME_HEIGHT_DP = 320 - -/** One bullet's budget, allowing for a sentence that wraps onto a second line. */ -private const val CHANGE_ROW_HEIGHT_DP = 52 - -/** - * How many bullets fit on this screen. Derived rather than fixed, for the same reason the - * detail pages derive their pane height: a panel that runs off the bottom of a 720p set has - * no scrollbar to aim at and no way for the remote to reach the button under it. If a - * release needs more room than the screen has, it loses bullets — never the button. - */ -internal fun maxChangesFor(availableHeightDp: Int): Int = - ((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES) - -private val Scrim = Color(0xE60A0C0F) - -/** - * What changed in the build that has just been installed, shown once over the launcher. - * - * Deliberately an overlay rather than a screen: the rows are already drawn behind it from - * the home cache, so the viewer sees their television working and this as a note on top of - * it, not another wait before it. Stateless — [whatsNewDecision] decides whether it appears - * and `SettingsStore.markWhatsNewSeen` records that it did — so it can be screenshotted - * with no store, no gateway and no session. - */ -@Composable -internal fun WhatsNewOverlay( - release: ReleaseNote, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val dismissFocus = remember { FocusRequester() } - var focused by remember { mutableStateOf(false) } - - // The launcher requests focus for its first row on the same frame, and on a slow set - // that request can land after this one. Ask again until it sticks rather than leaving a - // panel on screen with the remote still driving the rows behind it. - LaunchedEffect(release.version) { - repeat(6) { - if (focused) return@LaunchedEffect - runCatching { dismissFocus.requestFocus() } - delay(100) - } - } - - BackHandler(enabled = true, onBack = onDismiss) - - var entered by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { entered = true } - val entrance by animateFloatAsState( - targetValue = if (entered) 1f else 0f, - animationSpec = tween(280), - label = "whats-new-entrance", - ) - - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .zIndex(8f) - .background(Scrim) - .testTag("whats-new"), - contentAlignment = Alignment.Center, - ) { - val shown = maxChangesFor(maxHeight.value.toInt()) - Column( - modifier = Modifier - .graphicsLayer { - alpha = entrance - translationY = (1f - entrance) * 18.dp.toPx() - } - .widthIn(max = 760.dp) - .clip(RoundedCornerShape(MembyPanelCorner)) - .background(MembySurfaceRaised) - .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(MembyPanelCorner)) - .padding(horizontal = 40.dp, vertical = 34.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "WHAT'S NEW", - color = MembyAccent, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 2.sp, - ) - Spacer(Modifier.height(10.dp)) - Text( - "Memby ${release.version}", - color = MembyOnSurface, - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - ) - if (release.date.isNotBlank()) { - Spacer(Modifier.height(6.dp)) - Text("Installed on this TV · ${release.date}", color = MembyQuietText, fontSize = 14.sp) - } - - Spacer(Modifier.height(22.dp)) - Column( - modifier = Modifier.widthIn(max = 620.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - release.changes.take(shown).forEach { ChangeRow(it) } - } - - if (release.changes.size > shown) { - Spacer(Modifier.height(16.dp)) - Text( - "And more — the full history is in Settings → About.", - color = MembyQuietText, - fontSize = 14.sp, - ) - } - - Spacer(Modifier.height(28.dp)) - UpdateButton( - label = "Continue", - primary = true, - enabled = true, - onClick = onDismiss, - modifier = Modifier - .focusRequester(dismissFocus) - // The only focusable thing on screen, over a launcher full of them. - // Every direction points back here, or one press of Down walks into - // rows the viewer cannot see behind the scrim. - .focusProperties { - up = dismissFocus - down = dismissFocus - left = dismissFocus - right = dismissFocus - } - .onFocusChanged { focused = it.isFocused } - .testTag("whats-new-continue"), - ) - } - } -} - -/** - * One bullet, as the sentence the changelog was written with and nothing else. - * - * It used to lift a leading "Fixed:" / "Added:" into an accent pill in a fixed 88dp column. - * Two things were wrong with that. The column was sized for the longest label, so a panel of - * three one-word tags spent a fifth of its width on them and every sentence started an inch - * from the margin; and the pills were the brightest thing on a screen whose job is to be - * read, so the eye went to four repetitions of the word FIXED rather than to what had been. - * The full line is shown instead — which is also exactly what Settings → About renders, so - * the two places a viewer reads release notes now agree. - */ -@Composable -private fun ChangeRow(change: String) { - Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Box( - modifier = Modifier - .padding(top = 8.dp) - .size(6.dp) - .clip(CircleShape) - .background(MembyAccent.copy(alpha = 0.7f)), - ) - Text(change, color = MembyMutedText, fontSize = 16.sp) - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05559c..ba691b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ %1$s speed Starting now… Memby + Memby has been updated to version %1$s. Memby Screensaver ponzischeme89 diff --git a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt index 6ef1634..31e612c 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt @@ -2,12 +2,63 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.EmbyPerson +import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Test class CastMetadataTest { + @Test + fun `episode without cast falls back to its series cast`() = runTest { + val seriesCast = listOf(actor("series-lead")) + val items = mapOf( + "episode" to BaseItem(id = "episode", type = "Episode", seriesId = "series"), + "series" to BaseItem(id = "series", type = "Series", people = seriesCast), + ) + val requested = mutableListOf() + + val result = resolveCast("episode") { id -> + requested += id + requireNotNull(items[id]) + } + + assertEquals(seriesCast, result) + assertEquals(listOf("episode", "series"), requested) + } + + @Test + fun `episode cast takes precedence over series cast`() = runTest { + val guestCast = listOf(actor("guest")) + val requested = mutableListOf() + + val result = resolveCast("episode") { id -> + requested += id + BaseItem( + id = id, + type = "Episode", + seriesId = "series", + people = guestCast, + ) + } + + assertEquals(guestCast, result) + assertEquals(listOf("episode"), requested) + } + + @Test + fun `non-episode without cast does not use a series fallback`() = runTest { + val requested = mutableListOf() + + val result = resolveCast("movie") { id -> + requested += id + BaseItem(id = id, type = "Movie", seriesId = "series") + } + + assertEquals(emptyList(), result) + assertEquals(listOf("movie"), requested) + } + @Test fun `cast contains actors and preserves Emby order`() { val item = BaseItem( @@ -45,4 +96,6 @@ class CastMetadataTest { assertEquals("person-1", item.cast.single().id) assertEquals("portrait-tag", item.cast.single().primaryImageTag) } + + private fun actor(id: String) = EmbyPerson(id = id, name = id, type = "Actor") } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index 4db5850..d7b80c4 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -118,11 +118,12 @@ class GatewayPayloadTest { @Test fun `decodes explicit client server protocol mismatch`() { val status = json.decodeFromString( - """{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","serverProtocol":1}""", + """{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1}""", ) assertEquals(false, status.compatible) assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage) + assertEquals("0.1.29", status.gatewayVersion) assertEquals(1, status.serverProtocol) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt new file mode 100644 index 0000000..8390025 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ProfileInitialsTest.kt @@ -0,0 +1,19 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProfileInitialsTest { + @Test + fun `uses camel case capitals for compact usernames`() { + assertEquals("AV", profileInitials("AlwynV")) + assertEquals("MC", profileInitials("MattC")) + } + + @Test + fun `uses word initials and safe short fallbacks`() { + assertEquals("MC", profileInitials("Matt Cohen")) + assertEquals("AL", profileInitials("alwyn")) + assertEquals("?", profileInitials(" ")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt index b32c2ad..b006370 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt @@ -58,4 +58,12 @@ class GenreBrowseTest { assertTrue(item.withFavourite(true).isFavorite) assertFalse(item.withFavourite(true).withFavourite(false).isFavorite) } + + @Test + fun `watched overrides update a paged poster immediately`() { + val item = BaseItem(id = "film", name = "Film", type = "Movie") + assertFalse(item.userData?.played == true) + assertTrue(item.withPlayed(true).userData?.played == true) + assertFalse(item.withPlayed(true).withPlayed(false).userData?.played == true) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewScreenshotTest.kt deleted file mode 100644 index 7596d18..0000000 --- a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewScreenshotTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.ponzischeme89.memby.ui.whatsnew - -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import com.ponzischeme89.memby.ui.PreviewSurface -import com.ponzischeme89.memby.ui.settings.ReleaseNote -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** - * The panel a television shows once, after it has updated itself overnight. Nobody sees it - * twice, so being able to look at it without reinstalling on hardware is the point. - */ -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") -class WhatsNewScreenshotTest { - - @get:Rule - val compose = createComposeRule() - - @Test - fun `a typical release`() { - compose.setContent { - PreviewSurface { - WhatsNewOverlay( - release = ReleaseNote( - version = "0.2.24", - date = "2026-08-05", - changes = listOf( - "Fixed: The centre button on the remote now pauses straight away.", - "Changed: Subtitles now open in a small menu above the button.", - "Added: Continuing and ended tags on My Shows.", - ), - ), - onDismiss = {}, - ) - } - } - compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new.png") - } - - @Test - fun `a long release is capped`() { - compose.setContent { - PreviewSurface { - WhatsNewOverlay( - release = ReleaseNote( - version = "0.2.24", - date = "2026-08-05", - changes = List(9) { "Fixed: Change number ${it + 1} in a long release." }, - ), - onDismiss = {}, - ) - } - } - compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new-long.png") - } -} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt index 6c21378..59b4620 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewTest.kt @@ -1,26 +1,19 @@ package com.ponzischeme89.memby.ui.whatsnew -import com.ponzischeme89.memby.ui.settings.ReleaseNote import org.junit.Assert.assertEquals import org.junit.Test class WhatsNewTest { - private val history = listOf( - ReleaseNote("0.2.24", "2026-08-05", listOf("Fixed: A thing.", "Added: Another thing.")), - ReleaseNote("0.2.23", "2026-08-04", listOf("Added: An older thing.")), - ) - private fun decide( installed: String = "0.2.24", seen: String? = "0.2.23", signedIn: Boolean = true, - releases: List = history, - ) = whatsNewDecision(installed, seen, signedIn, releases) + ) = whatsNewDecision(installed, seen, signedIn) @Test - fun `an updated television is shown the notes for the build it is running`() { - assertEquals(WhatsNewDecision.Show(history[0]), decide()) + fun `an updated television is notified of the build it is running`() { + assertEquals(WhatsNewDecision.Notify("0.2.24"), decide()) } @Test @@ -38,23 +31,17 @@ class WhatsNewTest { @Test fun `an install that predates the record is announced once it is signed in`() { - assertEquals(WhatsNewDecision.Show(history[0]), decide(seen = null, signedIn = true)) + assertEquals(WhatsNewDecision.Notify("0.2.24"), decide(seen = null, signedIn = true)) } @Test - fun `a signed-out television with a record waits rather than burning the notes`() { + fun `a signed-out television with a record waits rather than consuming the notice`() { assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.23", signedIn = false)) } @Test - fun `a build the changelog does not describe is recorded silently`() { - assertEquals(WhatsNewDecision.MarkSeen("0.9.0"), decide(installed = "0.9.0")) - } - - @Test - fun `a release with no bullets is not shown as an empty panel`() { - val empty = listOf(ReleaseNote("0.2.24", "2026-08-05", emptyList())) - assertEquals(WhatsNewDecision.MarkSeen("0.2.24"), decide(releases = empty)) + fun `an updated build does not depend on a changelog entry`() { + assertEquals(WhatsNewDecision.Notify("0.9.0"), decide(installed = "0.9.0")) } @Test @@ -62,13 +49,4 @@ class WhatsNewTest { assertEquals(WhatsNewDecision.Nothing, decide(installed = " ")) } - @Test - fun `the bullet count is cut to what the screen can hold`() { - // A 720p television, which is the small case this exists for. - assertEquals(4, maxChangesFor(540)) - // Room for more does not mean an unbounded list. - assertEquals(6, maxChangesFor(1200)) - // Never zero: a panel with a heading and nothing under it says nothing at all. - assertEquals(1, maxChangesFor(200)) - } } diff --git a/server/internal/api/features.go b/server/internal/api/features.go index 1100e9b..2db1065 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -24,6 +24,7 @@ const ( featureEndCredits = "end_credits" featureSeasonalThemes = "seasonal_themes" featureSeasonalDecorations = "seasonal_decorations" + featureGenreBrowser = "genre_browser" ) type featureDefinition struct { @@ -117,6 +118,12 @@ var featureCatalogue = []featureDefinition{ DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1", Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.", }, + { + Key: featureGenreBrowser, Name: "Genre browser", Area: "Presentation", + Description: "Show the full genre browser at the top of Movies and TV Shows.", + DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1", + Recovery: "Takes effect on the next status poll; the browser is hidden when off.", + }, { Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup", Description: "Ask a signed-in TV that cannot install its own updates to grant the " + diff --git a/server/internal/api/features_test.go b/server/internal/api/features_test.go index b92ac75..f3494ac 100644 --- a/server/internal/api/features_test.go +++ b/server/internal/api/features_test.go @@ -47,6 +47,20 @@ func TestHEVCDirectPlayIsVisibleAndServerControllable(t *testing.T) { } } +func TestGenreBrowserIsServerControlledAndOffByDefault(t *testing.T) { + definition, ok := knownFeature(featureGenreBrowser) + if !ok || definition.Capability != "genre_browser_v1" { + t.Fatalf("genre browser feature definition = %+v, found=%v", definition, ok) + } + if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1); got.Enabled { + t.Fatalf("genre browser default = %+v, want disabled", got) + } + enabled := store.FeaturePolicy{Overrides: map[string]bool{featureGenreBrowser: true}} + if got := evaluateFeature(enabled, definition, 1); !got.Enabled || got.Source != "override" { + t.Fatalf("genre browser override = %+v", got) + } +} + func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+ diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index b20d981..5c87fb8 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/ponzischeme89/memby/server/internal/buildinfo" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -119,6 +120,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses "compatibilityMessage": compatibilityMessage, "clientVersion": clientVersion(r), "clientProtocol": clientProtocol(r), + "gatewayVersion": buildinfo.Version(), "serverProtocol": ProtocolVersion, "featureSchemaVersion": featureSchemaVersion, "featureRevision": featurePolicy.Revision, diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index a2dd1d3..5ef49d2 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.29 \ No newline at end of file +0.1.29