This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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,"+
|
||||
|
||||
@@ -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 @@
|
||||
0.1.29
|
||||
0.1.29
|
||||
|
||||
Reference in New Issue
Block a user