Release v0.2.38

This commit is contained in:
ponzischeme89
2026-08-09 16:04:40 +12:00
parent 0dffd59440
commit d13f52bba7
32 changed files with 442 additions and 472 deletions
+12
View File
@@ -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.
+9 -16
View File
@@ -137,22 +137,15 @@ About as one collapsible release per entry. So a release edits one file and the
stays readable offline. Keep the `## <version> — <date>` / `- 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
+61 -25
View File
@@ -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.
```
+1 -1
View File
@@ -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()
@@ -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<EmbyPerson> {
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()
}
@@ -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<Boolean> = _installPermissionPrompt.asStateFlow()
/** Server-controlled because the browser layout is still being refined. */
val genreBrowserEnabled: StateFlow<Boolean> = _genreBrowserEnabled.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
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
@@ -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 }
}
}
}
}
@@ -376,10 +376,10 @@ data class Settings(
*/
val onboardedUserIds: Set<String> = 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()
@@ -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,
@@ -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",
@@ -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
@@ -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<Map<String, Boolean>>(emptyMap())
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
private val _playedChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val playedChanges: StateFlow<Map<String, Boolean>> = _playedChanges.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _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)
}
}
}
}
@@ -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<String?>(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<ReleaseNote?>(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<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(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<BaseItem?>(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 {
@@ -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,
@@ -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<String, Boolean>,
playedStates: Map<String, Boolean>,
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) {
@@ -100,6 +100,28 @@ class GenreBrowseViewModel(
}
}
/** Applies optimistic watched changes to the paged copies without losing position. */
fun applyPlayedStates(changes: Map<String, Boolean>) {
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()) {
@@ -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,
),
)
@@ -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
@@ -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<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = 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<List<GatewayDevice>>(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) {
@@ -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<ReleaseNote>,
): 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.
@@ -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)
}
}
+1
View File
@@ -87,6 +87,7 @@
<string name="end_credits_speed">%1$s speed</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="developer_name">ponzischeme89</string>
</resources>
@@ -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<String>()
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<String>()
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<String>()
val result = resolveCast("movie") { id ->
requested += id
BaseItem(id = id, type = "Movie", seriesId = "series")
}
assertEquals(emptyList<EmbyPerson>(), 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")
}
@@ -118,11 +118,12 @@ class GatewayPayloadTest {
@Test
fun `decodes explicit client server protocol mismatch`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"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)
}
@@ -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(" "))
}
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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<ReleaseNote> = 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))
}
}
+7
View File
@@ -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 " +
+14
View File
@@ -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,"+
+2
View File
@@ -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,
+1 -1
View File
@@ -1 +1 @@
0.1.29
0.1.29