This commit is contained in:
ponzischeme89
2026-08-22 06:29:31 +12:00
parent 8b3d80a0a6
commit ac4cf32746
38 changed files with 1723 additions and 649 deletions
@@ -0,0 +1 @@
e76e2be01f9477bf3dae4d4f5582546f
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+70
View File
@@ -1,8 +1,78 @@
## 0.2.98 - 2026-08-22
- v298 closes the following issues: 116 (Feature: Integrate Memby with Android TV Home Screen), 117 (Improve Row Navigation & Add “View All” Actions) and 115 (Add ratings strip to movie continue watching).
- Added: Continue Watching support on the Google TV launcher homescreen (116)
- Added: Ratings strip to continue watching cards (115)
- Added: Buttons to rows which can take you back to main genre (117).
- Improved: Cleaned up genres chips row on search page.
- Improved: Switched cards to list cards for Search.
- Fixed: RequeststCards.kt shares its component with Search.
- Improved: Bug fixes & improvements to ResumeableMediaCard.kt, ContinueWatchingCard.kt
- Updated: Server updated to v0.1.64
- Publishes partially watched movies/episodes and Next Up episodes through Android TVs native Watch Next API.
- Updates launcher progress from existing playback reports.
- Removes completed, stale, deleted, duplicate, signed-out, or previous-user entries.
- Launcher cards deep-link through a validated activity directly into PlayerActivity, resuming from current Emby/Memby state.
- Uses appropriate episode, series, season, runtime, description, and backdrop/poster metadata.
- Keeps provider failures and offline reconciliation completely supplementary to playback.
- Added an isolated tvhome architecture:
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt
- app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt
- app/src/main/java/com/ponzischeme89/memby/tvhome/HomeChannelPublisher.kt
## 0.2.97 - 2026-08-21
- Improved: Modularised MembyButtons, ProfileChooser, SettingsSheet, ViewerControls
- Added: Traliers no longer show controls that are outside of our control (e.g. Youtube)
- Improved: Simplified the Emby exit screen prompt. Moved to EmbyMembyScreen.kt
## 0.2.96 - 2026-08-21
- Improved: Refactor EmbyRepo
- Improved: HomeNavigation.kt
- Added: ServiceLocator.kt
## 0.2.95 - 2026-08-21
- Improved: MetadataHero.kt
- Added: MetadataHero.kt opts to Preferences
## 0.2.94 - 2026-08-21
- Improved: MetadataHero.kt
## 0.2.93 - 2026-08-21
- Fixed: Bugs with HomeOverlay
## 0.2.92 - 2026-08-21 ## 0.2.92 - 2026-08-21
- Improved: App design / layout tweaks. - Improved: App design / layout tweaks.
- Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother. - Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother.
- Improved: Gateway updated to version 0.1.63. - Improved: Gateway updated to version 0.1.63.
## 0.2.91 - 2026-08-21
- Fixed: Bugs with HomeComponents/HomeScreen
- Fixed: Bugs with FocusScaleContainer
- Improved: LandscapeCard, ContinueWatchingCard, MediaBadges, MediaCardMetadata, MediaCardSizing, MediaLoading, PosterCard
## 0.2.90 - 2026-08-21
- Fixed: Padding fixes with HomeComponents.kt
## 0.2.89 - 2026-08-21
- Fixed: Responsive row sizing fixes
## 0.2.88 - 2026-08-21
- Fixed: More bug fixes to HomeComponents.kt, HomeScreen.kt.
- Tested: Tests passed with HomeMovieHeroTest.
## 0.2.87 - 2026-08-21
- Fixed: Bugs with HomeComponents.kt
- Fixed: Bugs with HomeOverlays.kt
- Fixed: Bugs with HomeScreen.kt
## 0.2.86 - 2026-08-21
- Fixed: Bugs with HomeComponents
## 0.2.85 - 2026-08-21
- Fixed: Bug with LaunchPreroll
- Fixed: Bug fixes to PrerollPreLoader
- Fixed: Bug which prevented ResumableMediaCardTest from firing
## 0.2.84 - 2026-08-21 ## 0.2.84 - 2026-08-21
- Improved: Loading and performance of the continue watching row. - Improved: Loading and performance of the continue watching row.
+13 -11
View File
@@ -708,7 +708,9 @@ of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
about a title somebody abandoned, and a feed carrying those is one nobody opens. about a title somebody abandoned, and a feed carrying those is one nobody opens.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed **Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. 6×6 on-screen keyboard on the left, with `SearchGenres` above `SearchResults` on the right.
The results list updates as you type and uses the same shared `MediaResultCard` presentation
as My Requests; request status and actions remain feature-owned slots around that shared row.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)`
`trim``distinctUntilChanged``collectLatest { repository.search(it) }` — and `trim``distinctUntilChanged``collectLatest { repository.search(it) }` — and
`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow `collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow
@@ -785,17 +787,17 @@ at a time. Things to preserve:
the server is the same judgement from the other side, for an Emby that did not count. the server is the same judgement from the other side, for an Emby that did not count.
- **A page is appended by its own offset**, never by order of arrival: a response for an - **A page is appended by its own offset**, never by order of arrival: a response for an
offset already scrolled past, or for a genre the viewer has left, is dropped rather than offset already scrolled past, or for a genre the viewer has left, is dropped rather than
pasted into the middle of the grid. A page that fails part way down keeps what is on pasted into the middle of the list. A page that fails part way down keeps what is on
screen and simply stops paging. screen and simply stops paging.
- **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index - **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index
changes on every frame of a scroll, and reading it in the body would recompose the grid changes on every frame of a scroll, and reading it in the body would recompose the list
the whole way down a genre — the same rule as the animation one above. It asks the whole way down a genre — the same rule as the animation one above. It asks
`LOAD_MORE_ROWS_AHEAD` rows early, since a request that starts when the viewer arrives at several items early, since a request that starts when the viewer arrives at
the end is one they watch. the end is one they watch.
- **Something has to take the focus the chip was holding**, because opening a shelf unmounts - **Genre and result focus targets are separate.** The genre strip stays mounted above the
the whole discovery pane. The grid claims it when the first page lands, and an empty or results; a genre result claims its own requester when the first page lands, Back restores
failed genre hands it back to the keyboard rather than leaving a television with nothing the strip, and an empty or failed genre hands focus back deterministically rather than
focused. leaving a television with nothing focused.
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so - **Episodes are excluded on both paths.** An episode inherits its series' genres, so
including them fills a page with twenty entries of one comedy and buries the rest. including them fills a page with twenty entries of one comedy and buries the rest.
- **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful - **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful
@@ -862,9 +864,9 @@ at a time. Things to preserve:
missing the route, and search results pasted onto the end of a genre would be nonsense. missing the route, and search results pasted onto the end of a genre would be nonsense.
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
rightmost goes to the results grid, the grid's first column goes back to the *last key rightmost goes to Genres, Down enters Search Results, and every result goes back to the
used* (a `FocusRequester` attached to whichever key that is), and the grid has a *last key used* (a `FocusRequester` attached to whichever key that is). Back closes an open
`focusRestorer`. Back moves results → keyboard → genre → leave, one step per press. genre shelf before moving results → keyboard → leave, one step per press.
Physical keyboards and phone-remote apps feed the same state through one Physical keyboards and phone-remote apps feed the same state through one
`onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back `onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back
must fall through. The voice button needs the `android.speech.RecognitionService` entry in must fall through. The voice button needs the `android.speech.RecognitionService` entry in
+3 -1
View File
@@ -64,7 +64,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.97" val defaultVersionName = "0.2.98"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -275,6 +275,8 @@ dependencies {
// Compose for TV // Compose for TV
implementation("androidx.tv:tv-material:1.1.0") implementation("androidx.tv:tv-material:1.1.0")
// Android TV's system-owned Watch Next row and optional app-owned home channels.
implementation("androidx.tvprovider:tvprovider:1.1.0")
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.7.0") implementation("io.coil-kt:coil-compose:2.7.0")
+22
View File
@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Publishes resumable programmes into Android TV's system-owned Watch Next row. -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). --> <!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets a self-update apply without a confirmation screen once Memby is its own <!-- Lets a self-update apply without a confirmation screen once Memby is its own
@@ -117,6 +119,26 @@
android:theme="@style/Theme.Memby.Fullscreen" android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" /> tools:ignore="DiscouragedApi" />
<!-- Exported only as the narrow, validated entry point used by TV launcher cards.
It resolves current Emby state, then hands playback to the private player. -->
<activity
android:name=".tvhome.TvDeepLinkActivity"
android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="play"
android:scheme="memby" />
</intent-filter>
</activity>
<!-- The system screensaver (Daydream / Ambient mode source). <!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. --> Interactive: select to open the panel, play, or favourite. -->
<service <service
@@ -12,6 +12,7 @@ import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.data.remote.StreamWarmer import com.ponzischeme89.memby.data.remote.StreamWarmer
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
import com.ponzischeme89.memby.update.RequiredUpdateGuard import com.ponzischeme89.memby.update.RequiredUpdateGuard
import com.ponzischeme89.memby.tvhome.TvHomeIntegrationService
/** /**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the * Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -65,6 +66,10 @@ object ServiceLocator {
internal lateinit var streamWarmer: StreamWarmer internal lateinit var streamWarmer: StreamWarmer
private set private set
/** Process-scoped, supplementary bridge to the television launcher. */
lateinit var tvHome: TvHomeIntegrationService
private set
fun init(context: Context) { fun init(context: Context) {
if (::repository.isInitialized) return if (::repository.isInitialized) return
// Only hands the probe an application context; it does no work until the first // Only hands the probe an application context; it does no work until the first
@@ -92,6 +97,8 @@ object ServiceLocator {
// because the surfaces that obey it — the launcher, the player's Compose islands, // because the surfaces that obey it — the launcher, the player's Compose islands,
// the screensaver's DreamService — are separate roots with no common owner but this. // the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme) themeSync = ThemeSync(repository, settings, maintenance.theme)
tvHome = TvHomeIntegrationService(context.applicationContext, repository, settings)
tvHome.start()
remoteConfig.refreshLater() remoteConfig.refreshLater()
} }
} }
@@ -2612,6 +2612,9 @@ class EmbyRepository internal constructor(
// evidence: a process killed between two reports still leaves the ledger describing // evidence: a process killed between two reports still leaves the ledger describing
// a position within ten seconds of the truth. // a position within ten seconds of the truth.
recordLocalResume(session.itemId, positionMs, durationMs) recordLocalResume(session.itemId, positionMs, durationMs)
_playbackPositions.tryEmit(
PlaybackPosition(session.itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
)
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
requireGateway().report( requireGateway().report(
"progress", "progress",
@@ -32,6 +32,7 @@ enum class PlaybackEntryPoint(val id: String) {
*/ */
DETAIL_PAGE("detail_page"), DETAIL_PAGE("detail_page"),
SCREENSAVER("screensaver"), SCREENSAVER("screensaver"),
TV_HOME("tv_home"),
UNKNOWN("unknown"), UNKNOWN("unknown"),
; ;
@@ -0,0 +1,26 @@
package com.ponzischeme89.memby.tvhome
import android.os.Build
import com.ponzischeme89.memby.data.model.HomeRow
/**
* Boundary for optional Memby-owned discovery rows.
*
* Channels are deliberately not created automatically: Android TV requires a foreground
* user approval flow for non-default channels, and discovery rows must not duplicate Watch
* Next. A future Settings action can pass server-composed rows through [eligibleRows] and
* publish the selected one without changing playback or [TvHomeIntegrationService].
* Google TV's managed Continue Watching programme is likewise not implemented here; it is
* a certified, server-fed integration rather than another local channel.
*/
internal class HomeChannelPublisher {
val supported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
fun eligibleRows(rows: List<HomeRow>): List<HomeRow> = rows.filter { row ->
row.items.isNotEmpty() && row.kind.lowercase() !in WATCH_NEXT_KINDS
}
private companion object {
val WATCH_NEXT_KINDS = setOf("continue", "nextup", "next_up")
}
}
@@ -0,0 +1,61 @@
package com.ponzischeme89.memby.tvhome
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint
import com.ponzischeme89.memby.ui.player.PlayerActivity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** Narrow exported trampoline for launcher-owned deep links; [PlayerActivity] stays private. */
class TvDeepLinkActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val itemId = TvDeepLinks.itemId(intent.data)
val requestedProfile = TvDeepLinks.profileKey(intent.data)
if (itemId == null || requestedProfile == null) {
finish()
return
}
lifecycleScope.launch {
val current = withTimeoutOrNull(SETTINGS_TIMEOUT_MS) {
ServiceLocator.settings.settingsFlow.first()
}
val profile = current?.let(::tvHomeProfile)
if (profile?.key != requestedProfile) {
finish()
return@launch
}
val item = runCatching { ServiceLocator.repository.getItemDetails(itemId) }.getOrNull()
if (item == null || item.userData?.played == true || (!item.isMovie && !item.isEpisode)) {
finish()
return@launch
}
val request = runCatching { ServiceLocator.repository.playbackRequest(item) }.getOrNull()
if (request == null) {
finish()
return@launch
}
startActivity(
PlayerActivity.intent(
context = this@TvDeepLinkActivity,
request = request,
posterUrl = ServiceLocator.repository.primaryUrl(item, maxWidth = 500),
backdropUrl = ServiceLocator.repository.backdropUrl(item, maxWidth = 1920)
?: ServiceLocator.repository.primaryUrl(item, maxWidth = 1920),
journeySource = PlaybackEntryPoint.TV_HOME.id,
),
)
finish()
}
}
private companion object {
const val SETTINGS_TIMEOUT_MS = 5_000L
}
}
@@ -0,0 +1,23 @@
package com.ponzischeme89.memby.tvhome
import android.net.Uri
internal object TvDeepLinks {
fun playback(itemId: String, profileKey: String): Uri = Uri.Builder()
.scheme("memby")
.authority("play")
.appendPath(itemId)
.appendQueryParameter("profile", profileKey)
.build()
fun itemId(uri: Uri?): String? = uri
?.takeIf { it.scheme == "memby" && it.host == "play" }
?.pathSegments
?.singleOrNull()
?.takeIf(String::isNotBlank)
fun profileKey(uri: Uri?): String? = uri
?.takeIf { it.scheme == "memby" && it.host == "play" }
?.getQueryParameter("profile")
?.takeIf(String::isNotBlank)
}
@@ -0,0 +1,141 @@
package com.ponzischeme89.memby.tvhome
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.PlaybackPosition
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* Process-scoped bridge between Memby's repositories and Android TV's launcher provider.
* Every operation is best-effort: a missing provider, a launcher defect, or an offline
* server can cost a launcher card but can never delay or fail playback.
*/
class TvHomeIntegrationService internal constructor(
context: Context,
private val repository: EmbyRepository,
private val settings: SettingsStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publisher = WatchNextPublisher(context)
private val reconciliation = Mutex()
private val latestPositions = ConcurrentHashMap<String, PlaybackPosition>()
private val completedItems = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var activeProfile: TvHomeProfile? = null
private var started = false
fun start() {
if (started) return
started = true
scope.launch {
settings.settingsFlow
.map(::tvHomeProfile)
.distinctUntilChanged()
.collectLatest { profile ->
latestPositions.clear()
completedItems.clear()
activeProfile = profile
if (profile == null) {
publisher.clear()
} else {
publisher.clearOtherProfiles(profile.key)
// A process may have been created by a launcher-card press. Keep the
// supplementary row refresh behind item lookup and stream negotiation.
delay(INITIAL_RECONCILE_DELAY_MS)
reconcile(profile)
}
}
}
scope.launch {
repository.playbackPositions.collect { position ->
activeProfile?.let { updateProgress(it, position) }
}
}
scope.launch {
repository.playbackStops.collect {
activeProfile?.let { profile -> reconcile(profile) }
}
}
}
private suspend fun reconcile(profile: TvHomeProfile) {
reconciliation.withLock {
if (activeProfile != profile) return
val items = runCatching { repository.getContinueWatching(WATCH_NEXT_LIMIT) }
.getOrNull()
?: return
if (activeProfile != profile) return
val serverIds = items.mapTo(mutableSetOf()) { it.id }
completedItems.retainAll(serverIds)
val now = System.currentTimeMillis()
val programmes = items.mapIndexedNotNull { index, item ->
if (item.id in completedItems) return@mapIndexedNotNull null
val local = latestPositions[item.id]
val localTicks = local?.positionMs?.times(10_000L) ?: 0L
val currentTicks = item.userData?.playbackPositionTicks ?: 0L
val currentItem = if (localTicks > currentTicks) {
item.copy(
userData = (item.userData ?: com.ponzischeme89.memby.data.model.UserItemData())
.copy(playbackPositionTicks = localTicks),
)
} else {
item
}
tvHomeProgram(
item = currentItem,
profileKey = profile.key,
artworkUrl = repository.backdropUrl(item, maxWidth = 1280)
?: repository.primaryUrl(item, maxWidth = 780),
// Preserve the repository's recency order even where a Next Up item has
// no LastPlayedDate of its own.
nowMs = now - index,
)
}
publisher.reconcile(programmes)
}
}
private fun updateProgress(profile: TvHomeProfile, position: PlaybackPosition) {
latestPositions[position.itemId] = position
if (position.completed) completedItems += position.itemId else completedItems -= position.itemId
publisher.updateProgress(
profileKey = profile.key,
itemId = position.itemId,
positionMs = position.positionMs,
durationMs = position.durationMs,
completed = position.completed,
)
}
private companion object {
const val WATCH_NEXT_LIMIT = 40
const val INITIAL_RECONCILE_DELAY_MS = 15_000L
}
}
internal data class TvHomeProfile(val key: String)
internal fun tvHomeProfile(settings: Settings): TvHomeProfile? {
if (!settings.isSignedIn) return null
return TvHomeProfile(
tvHomeProfileKey(
serverUrl = requireNotNull(settings.serverUrl),
userId = requireNotNull(settings.userId),
viewerId = settings.activeViewerId,
),
)
}
@@ -0,0 +1,69 @@
package com.ponzischeme89.memby.tvhome
import com.ponzischeme89.memby.data.model.BaseItem
import java.security.MessageDigest
internal enum class TvHomeProgramKind { CONTINUE, NEXT }
internal data class TvHomeProgram(
val providerId: String,
val itemId: String,
val profileKey: String,
val kind: TvHomeProgramKind,
val mediaType: String,
val title: String,
val episodeTitle: String? = null,
val description: String? = null,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val artworkUrl: String? = null,
val engagementTimeMs: Long,
)
internal fun tvHomeProfileKey(serverUrl: String, userId: String, viewerId: String): String {
val identity = "${serverUrl.trimEnd('/')}|$userId|$viewerId"
return MessageDigest.getInstance("SHA-256")
.digest(identity.toByteArray(Charsets.UTF_8))
.take(12)
.joinToString("") { "%02x".format(it) }
}
internal fun tvHomeProgram(
item: BaseItem,
profileKey: String,
artworkUrl: String?,
nowMs: Long,
): TvHomeProgram? {
if (item.id.isBlank() || item.userData?.played == true) return null
if (!item.isMovie && !item.isEpisode) return null
val positionMs = ((item.userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
val durationMs = ((item.runTimeTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
val kind = when {
positionMs > 0L -> TvHomeProgramKind.CONTINUE
item.isEpisode -> TvHomeProgramKind.NEXT
else -> return null
}
if (kind == TvHomeProgramKind.CONTINUE && durationMs > 0L && positionMs >= durationMs * 95 / 100) {
return null
}
return TvHomeProgram(
providerId = "memby:$profileKey:${item.id}",
itemId = item.id,
profileKey = profileKey,
kind = kind,
mediaType = if (item.isMovie) "movie" else "episode",
title = if (item.isEpisode) item.seriesName?.takeIf(String::isNotBlank) ?: item.name else item.name,
episodeTitle = item.name.takeIf { item.isEpisode },
description = item.overview,
seasonNumber = item.parentIndexNumber,
episodeNumber = item.indexNumber,
positionMs = positionMs,
durationMs = durationMs,
artworkUrl = artworkUrl,
engagementTimeMs = nowMs,
)
}
@@ -0,0 +1,150 @@
package com.ponzischeme89.memby.tvhome
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.BaseColumns
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.WatchNextProgram
internal class WatchNextPublisher(context: Context) {
private val resolver = context.applicationContext.contentResolver
val supported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
fun reconcile(programmes: List<TvHomeProgram>) {
if (!supported) return
runCatching {
val existing = existingProgrammes()
val desired = programmes.associateBy(TvHomeProgram::providerId)
for ((providerId, rows) in existing) {
val programme = desired[providerId]
if (programme == null) {
rows.forEach(::delete)
} else {
val keeper = rows.first()
resolver.update(keeper.uri, programme.toWatchNext().toContentValues(), null, null)
rows.drop(1).forEach(::delete)
}
}
for ((providerId, programme) in desired) {
if (providerId !in existing) {
resolver.insert(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
programme.toWatchNext().toContentValues(),
)
}
}
}
}
fun updateProgress(profileKey: String, itemId: String, positionMs: Long, durationMs: Long, completed: Boolean) {
if (!supported) return
runCatching {
val providerId = "memby:$profileKey:$itemId"
val rows = existingProgrammes()[providerId].orEmpty()
if (completed) {
rows.forEach(::delete)
return@runCatching
}
rows.firstOrNull()?.let { row ->
val values = android.content.ContentValues().apply {
put(TvContractCompat.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS, positionMs.toIntMillis())
if (durationMs > 0L) {
put(TvContractCompat.WatchNextPrograms.COLUMN_DURATION_MILLIS, durationMs.toIntMillis())
}
put(TvContractCompat.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS, System.currentTimeMillis())
}
resolver.update(row.uri, values, null, null)
rows.drop(1).forEach(::delete)
}
}
}
fun clear() {
if (!supported) return
runCatching { existingProgrammes().values.flatten().forEach(::delete) }
}
fun clearOtherProfiles(profileKey: String) {
if (!supported) return
val ownPrefix = "memby:$profileKey:"
runCatching {
existingProgrammes()
.filterKeys { !it.startsWith(ownPrefix) }
.values
.flatten()
.forEach(::delete)
}
}
private fun existingProgrammes(): Map<String, List<ExistingProgramme>> {
val found = linkedMapOf<String, MutableList<ExistingProgramme>>()
resolver.query(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
arrayOf(BaseColumns._ID, TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID),
null,
null,
null,
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(BaseColumns._ID)
val providerColumn = cursor.getColumnIndexOrThrow(
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
)
while (cursor.moveToNext()) {
val providerId = cursor.getString(providerColumn) ?: continue
if (!providerId.startsWith("memby:")) continue
val uri = ContentUris.withAppendedId(
TvContractCompat.WatchNextPrograms.CONTENT_URI,
cursor.getLong(idColumn),
)
found.getOrPut(providerId) { mutableListOf() } += ExistingProgramme(uri)
}
}
return found
}
private fun delete(programme: ExistingProgramme) {
resolver.delete(programme.uri, null, null)
}
private fun TvHomeProgram.toWatchNext(): WatchNextProgram {
val builder = WatchNextProgram.Builder()
builder.setType(
if (mediaType == "movie") {
TvContractCompat.PreviewPrograms.TYPE_MOVIE
} else {
TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE
},
)
builder.setWatchNextType(
if (kind == TvHomeProgramKind.CONTINUE) {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
} else {
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
},
)
builder.setTitle(title)
builder.setInternalProviderId(providerId)
builder.setContentId(itemId)
builder.setIntentUri(TvDeepLinks.playback(itemId, profileKey))
builder.setLastEngagementTimeUtcMillis(engagementTimeMs)
description?.takeIf(String::isNotBlank)?.let(builder::setDescription)
artworkUrl?.let(Uri::parse)?.let(builder::setPosterArtUri)
if (kind == TvHomeProgramKind.CONTINUE) {
builder.setLastPlaybackPositionMillis(positionMs.toIntMillis())
builder.setDurationMillis(durationMs.toIntMillis())
}
episodeTitle?.let(builder::setEpisodeTitle)
seasonNumber?.let(builder::setSeasonNumber)
episodeNumber?.let(builder::setEpisodeNumber)
return builder.build()
}
private fun Long.toIntMillis(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt()
private data class ExistingProgramme(val uri: Uri)
}
@@ -854,7 +854,7 @@ internal fun ForYouNudgeBanner(
} }
/** /**
* Turns Emby account-style usernames into a friendlier home-screen name. * Turns Memby account-style usernames into a friendlier home-screen name.
* *
* Household accounts commonly use a trailing capital as a disambiguating surname * Household accounts commonly use a trailing capital as a disambiguating surname
* initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary * initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary
@@ -285,6 +285,9 @@ internal fun HomeScreen(
var quickMenuTrailerAvailable by remember { mutableStateOf(false) } var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) } var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) } var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseTarget by remember { mutableStateOf<HomeRowBrowseTarget?>(null) }
var rowBrowseOriginRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseOriginItemId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) { var sectionHeroRows by remember(settings.userId) {
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap()) mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
} }
@@ -843,6 +846,7 @@ internal fun HomeScreen(
) )
LaunchedEffect(selectedDestination) { LaunchedEffect(selectedDestination) {
focusedHomeRowId = null focusedHomeRowId = null
rowBrowseTarget = null
} }
LaunchedEffect(selectedDestination, settings.forYouMinutes) { LaunchedEffect(selectedDestination, settings.forYouMinutes) {
if ( if (
@@ -863,6 +867,7 @@ internal fun HomeScreen(
showNotifications -> "notifications" showNotifications -> "notifications"
userSwitcherVisible || showProfiles -> "profiles" userSwitcherVisible || showProfiles -> "profiles"
detailsItem != null -> "details" detailsItem != null -> "details"
rowBrowseTarget != null -> "row_browse"
selectedMyShow != null -> "my_show_details" selectedMyShow != null -> "my_show_details"
else -> selectedDestination.name.lowercase() else -> selectedDestination.name.lowercase()
} }
@@ -935,6 +940,7 @@ internal fun HomeScreen(
navigationExpanded = it navigationExpanded = it
}, },
onDestinationSelected = { destination -> onDestinationSelected = { destination ->
rowBrowseTarget = null
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "navigation", action = "select", category = "navigation", action = "select",
screen = journeyScreen, feature = destination.name.lowercase(), screen = journeyScreen, feature = destination.name.lowercase(),
@@ -1050,6 +1056,53 @@ internal fun HomeScreen(
return@BoxWithConstraints return@BoxWithConstraints
} }
rowBrowseTarget?.let { browseTarget ->
GenreBrowseScreen(
itemType = browseTarget.itemType,
initialCategoryId = browseTarget.categoryId,
favouriteStates = favoriteChanges,
playedStates = playedChanges,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf {
returnRowId == ROW_BROWSE_RESULTS_ID
},
returnFocusRequester = cardReturnFocusRequester,
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = ROW_BROWSE_RESULTS_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
category = "content", action = "open", screen = "row_browse",
feature = "view_all", source = "row_browse_results",
target = "details", itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsItem = item
},
onContentFocused = { navigationExpanded = false },
onClose = {
rowBrowseTarget = null
returnRowId = rowBrowseOriginRowId
returnRowKind = rows.firstOrNull {
it.id == rowBrowseOriginRowId
}?.kind?.name
returnItemId = rowBrowseOriginItemId
scope.launch {
delay(16.milliseconds)
if (rowBrowseOriginItemId != null) {
runCatching { cardReturnFocusRequester.requestFocus() }
} else {
runCatching { contentFocusRequester.requestFocus() }
}
}
},
)
return@BoxWithConstraints
}
if (selectedDestination == BrowseDestination.SEARCH) { if (selectedDestination == BrowseDestination.SEARCH) {
// Remembered, or this list is a fresh instance on every recomposition // Remembered, or this list is a fresh instance on every recomposition
// and the screen re-derives its genre chips each time. // and the screen re-derives its genre chips each time.
@@ -1511,6 +1564,7 @@ internal fun HomeScreen(
row.items.take(8).map { it.id }, row.items.take(8).map { it.id },
) )
} }
val browseTarget = homeRowBrowseTarget(row)
MediaRow( MediaRow(
modifier = Modifier, modifier = Modifier,
row = row, row = row,
@@ -1672,6 +1726,24 @@ internal fun HomeScreen(
horizontalState = horizontalStates.getOrPut( horizontalState = horizontalStates.getOrPut(
"${selectedDestination.name}:${row.id}", "${selectedDestination.name}:${row.id}",
) { LazyListState() }, ) { LazyListState() },
viewAllLabel = browseTarget?.actionLabel,
onViewAll = browseTarget?.let { target ->
{
rowBrowseOriginRowId = row.id
rowBrowseOriginItemId = returnItemId.takeIf {
returnRowId == row.id
} ?: row.items.getOrNull(
rowFocusPositions[row.id] ?: 0,
)?.id
rowBrowseTarget = target
homeViewModel.trackJourney(
category = "navigation", action = "open",
screen = selectedDestination.name.lowercase(),
feature = "view_all", source = row.id,
target = "row_browse",
)
}
},
) )
} }
} }
@@ -141,7 +141,7 @@ internal fun expectedFinishEpochMillis(
* A self-contained card for anything that can be resumed. * A self-contained card for anything that can be resumed.
* *
* The component owns the progress, episode identity and expected-finish presentation; * The component owns the progress, episode identity and expected-finish presentation;
* callers own placement, artwork shape and actions. * callers own placement, artwork shape, actions and optional content beneath the title.
*/ */
@Composable @Composable
fun ResumableMediaCard( fun ResumableMediaCard(
@@ -152,6 +152,7 @@ fun ResumableMediaCard(
onFocused: () -> Unit, onFocused: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val artworkUrl = if (portraitArtwork) { val artworkUrl = if (portraitArtwork) {
@@ -299,6 +300,7 @@ fun ResumableMediaCard(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
) )
titleSupplement?.invoke(focused)
model.episodeLabel?.let { label -> model.episodeLabel?.let { label ->
Text( Text(
text = label, text = label,
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui.components.media package com.ponzischeme89.memby.ui.components.media
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -7,6 +8,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.ItemRatingsStrip
import com.ponzischeme89.memby.ui.ResumableMediaCard import com.ponzischeme89.memby.ui.ResumableMediaCard
import com.ponzischeme89.memby.ui.responsiveRowCardWidth import com.ponzischeme89.memby.ui.responsiveRowCardWidth
import com.ponzischeme89.memby.ui.toResumableMediaCardModel import com.ponzischeme89.memby.ui.toResumableMediaCardModel
@@ -55,5 +57,18 @@ fun ContinueWatchingCard(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier, modifier = modifier,
titleSupplement = if (item.isMovie) {
{ focused ->
ItemRatingsStrip(
item = item,
load = focused,
modifier = Modifier.padding(top = 2.dp),
reserveSpace = false,
compact = true,
)
}
} else {
null
},
) )
} }
@@ -0,0 +1,214 @@
package com.ponzischeme89.memby.ui.components.media
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import androidx.tv.material3.Icon
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/**
* The shared film/series result row used by Search and My Requests.
*
* This component owns the invariant 118dp row, 214dp artwork, typography, spacing and
* focus border. Feature-specific state belongs in the optional artwork, body and trailing
* slots: Requests supplies status and actions, while Search supplies library metadata.
*/
@Composable
internal fun MediaResultCard(
title: String,
subtitle: String,
detail: String,
artworkUrl: String?,
contentDescription: String,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
detailColour: Color = MembyAccent,
artworkTopStartContent: (@Composable () -> Unit)? = null,
artworkBottomStartContent: (@Composable () -> Unit)? = null,
bodyContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable (focused: Boolean) -> Unit)? = null,
) {
val shape = RoundedCornerShape(MembyCardCorner)
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = contentDescription,
modifier = modifier.fillMaxWidth(),
) { focused ->
Row(
Modifier
.fillMaxWidth()
.height(118.dp)
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.72f))
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
shape,
),
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) {
Box(
Modifier.fillMaxSize().background(
Brush.linearGradient(
listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised),
),
),
contentAlignment = Alignment.Center,
) {
Text(
title.trim().firstOrNull()?.uppercase().orEmpty(),
color = Color.White.copy(alpha = 0.16f),
fontSize = 52.sp,
fontWeight = FontWeight.Black,
)
}
if (!artworkUrl.isNullOrBlank()) {
AsyncImage(
model = artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised),
),
),
)
artworkTopStartContent?.let { content ->
Box(Modifier.align(Alignment.TopStart).padding(9.dp)) { content() }
}
artworkBottomStartContent?.let { content ->
Box(Modifier.align(Alignment.BottomStart).padding(9.dp)) { content() }
}
}
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotBlank()) {
Spacer(Modifier.height(5.dp))
Text(
subtitle,
color = Color.White.copy(alpha = 0.82f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
bodyContent?.invoke()
Spacer(Modifier.height(4.dp))
Text(
detail,
color = detailColour,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
trailingContent?.invoke(focused)
}
}
}
/** Film/series mark shared by both result surfaces. */
@Composable
internal fun MediaResultTypeGlyph(isSeries: Boolean, modifier: Modifier = Modifier) {
Box(
modifier
.size(24.dp)
.background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
if (isSeries) MembyIcon.Tv.mark else MembyIcon.Movie.mark,
contentDescription = if (isSeries) "Series" else "Film",
tint = Color.White.copy(alpha = 0.82f),
modifier = Modifier.size(13.dp),
)
}
}
/** Loading form of [MediaResultCard]; intentionally not focusable. */
@Composable
internal fun MediaResultCardSkeleton(modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier
.fillMaxWidth()
.height(118.dp)
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.34f))
.border(1.dp, Color.White.copy(alpha = 0.05f), shape),
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f)))
Column(
Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp),
verticalArrangement = Arrangement.Center,
) {
SkeletonBar(widthFraction = 0.52f, height = 15.dp)
Spacer(Modifier.height(9.dp))
SkeletonBar(widthFraction = 0.32f, height = 11.dp)
Spacer(Modifier.height(7.dp))
SkeletonBar(widthFraction = 0.22f, height = 9.dp)
}
}
}
@Composable
private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) {
Box(
Modifier
.fillMaxWidth(widthFraction)
.height(height)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)),
)
}
@@ -27,10 +27,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,11 +55,11 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// No NEXT_UP: those episodes are part of CONTINUE, which is one row. // No NEXT_UP: those episodes are part of CONTINUE, which is one row.
@@ -173,11 +175,18 @@ internal fun MediaRow(
density: String = "standard", density: String = "standard",
artworkStyle: String = "automatic", artworkStyle: String = "automatic",
horizontalState: LazyListState? = null, horizontalState: LazyListState? = null,
viewAllLabel: String? = null,
onViewAll: (() -> Unit)? = null,
) { ) {
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() } val verticalEntryFocusRequester = remember { FocusRequester() }
val scope = rememberCoroutineScope() val previousPageFocusRequester = remember { FocusRequester() }
val nextPageFocusRequester = remember { FocusRequester() }
val viewAllFocusRequester = remember { FocusRequester() }
val pagedCardFocusRequester = remember { FocusRequester() }
var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) }
var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
val requestedEntryIndex = verticalFocusRequest val requestedEntryIndex = verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex ?.itemIndex
@@ -205,10 +214,22 @@ internal fun MediaRow(
} }
currentOnVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
} }
LaunchedEffect(pageFocusRequestId) {
val target = pagedCardIndex ?: return@LaunchedEffect
rowState.scrollToItem(target)
// The target can begin outside the composed LazyRow window. Give layout a frame to
// attach it, then keep the transfer bounded so a refreshed row can never trap focus.
repeat(6) {
delay(16.milliseconds)
if (runCatching { pagedCardFocusRequester.requestFocus() }.isSuccess) {
return@LaunchedEffect
}
}
}
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header. // only the button's enabled/disabled flip recomposes the row header.
val canScrollBack by remember(rowState) { val canScrollBack by remember(rowState) {
derivedStateOf { rowState.firstVisibleItemIndex > 0 } derivedStateOf { rowState.canScrollBackward }
} }
val canScrollForward by remember(rowState) { val canScrollForward by remember(rowState) {
derivedStateOf { rowState.canScrollForward } derivedStateOf { rowState.canScrollForward }
@@ -232,22 +253,34 @@ internal fun MediaRow(
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) { if (row.items.isNotEmpty()) {
if (viewAllLabel != null && onViewAll != null) {
ViewAllButton(
label = viewAllLabel,
focusRequester = viewAllFocusRequester,
onClick = onViewAll,
)
Spacer(Modifier.width(8.dp))
}
GalleryJumpButton( GalleryJumpButton(
forward = false, forward = false,
enabled = canScrollBack, enabled = canScrollBack,
focusRequester = previousPageFocusRequester,
onClick = { onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0) val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
scope.launch { rowState.scrollToItem(target) } pagedCardIndex = target
pageFocusRequestId += 1
}, },
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
GalleryJumpButton( GalleryJumpButton(
forward = true, forward = true,
enabled = canScrollForward, enabled = canScrollForward,
focusRequester = nextPageFocusRequester,
onClick = { onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize) val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex) .coerceAtMost(row.items.lastIndex)
scope.launch { rowState.scrollToItem(target) } pagedCardIndex = target
pageFocusRequestId += 1
}, },
) )
} }
@@ -321,6 +354,9 @@ internal fun MediaRow(
if (index == requestedEntryIndex) { if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester) cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
} }
if (index == pagedCardIndex) {
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
}
cardModifier = cardModifier.onPreviewKeyEvent { event -> cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) { if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
@@ -328,6 +364,29 @@ internal fun MediaRow(
when (event.key) { when (event.key) {
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP) Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN) Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionLeft -> {
val firstVisible = rowState.layoutInfo.visibleItemsInfo
.firstOrNull()?.index
if (canScrollBack && index == firstVisible) {
runCatching { previousPageFocusRequester.requestFocus() }
.isSuccess
} else {
false
}
}
Key.DirectionRight -> {
val lastVisible = rowState.layoutInfo.visibleItemsInfo
.lastOrNull()?.index
if (index != lastVisible) {
false
} else if (viewAllLabel != null && onViewAll != null) {
runCatching { viewAllFocusRequester.requestFocus() }.isSuccess
} else if (canScrollForward) {
runCatching { nextPageFocusRequester.requestFocus() }.isSuccess
} else {
false
}
}
else -> false else -> false
} }
} }
@@ -374,6 +433,7 @@ internal fun MediaRow(
private fun GalleryJumpButton( private fun GalleryJumpButton(
forward: Boolean, forward: Boolean,
enabled: Boolean, enabled: Boolean,
focusRequester: FocusRequester,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
FocusScaleContainer( FocusScaleContainer(
@@ -381,12 +441,11 @@ private fun GalleryJumpButton(
onClick = { if (enabled) onClick() }, onClick = { if (enabled) onClick() },
onLongClick = null, onLongClick = null,
contentDescription = if (forward) "Next page" else "Previous page", contentDescription = if (forward) "Next page" else "Previous page",
// These mirror remote left/right paging for pointer users. Keeping them out of
// the focus graph prevents Up from a card landing in a tiny header control.
modifier = Modifier modifier = Modifier
.width(42.dp) .width(42.dp)
.height(36.dp) .height(36.dp)
.focusProperties { canFocus = false }, .focusRequester(focusRequester)
.focusProperties { canFocus = enabled },
) { focused -> ) { focused ->
Box( Box(
Modifier Modifier
@@ -394,10 +453,15 @@ private fun GalleryJumpButton(
.clip(RoundedCornerShape(MembyChipCorner)) .clip(RoundedCornerShape(MembyChipCorner))
.background( .background(
when { when {
focused -> Color.White.copy(alpha = 0.16f) focused -> MembyAccent.copy(alpha = 0.34f)
enabled -> Color.White.copy(alpha = 0.08f) enabled -> Color.White.copy(alpha = 0.08f)
else -> Color.White.copy(alpha = 0.03f) else -> Color.White.copy(alpha = 0.03f)
}, },
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -411,6 +475,53 @@ private fun GalleryJumpButton(
} }
} }
@Composable
private fun ViewAllButton(
label: String,
focusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = onClick,
contentDescription = label,
modifier = Modifier
.height(36.dp)
.focusRequester(focusRequester),
) { focused ->
Row(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(MembyChipCorner))
.background(
if (focused) MembyAccent.copy(alpha = 0.34f)
else Color.White.copy(alpha = 0.08f),
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
Icon(
imageVector = MembyIcon.ChevronRight.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(17.dp),
)
}
}
}
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat( private fun cardFormat(
@@ -0,0 +1,59 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID
import com.ponzischeme89.memby.ui.genre.genreCategoryTabs
internal const val ROW_BROWSE_RESULTS_ID = "row-browse-results"
/** The existing paged catalogue screen a home shelf can expand into. */
internal data class HomeRowBrowseTarget(
val categoryId: String,
val itemType: String,
val actionLabel: String,
)
/**
* Resolves only shelves whose complete catalogue query is known on both backend paths.
*
* Recommendation and studio shelves deliberately return null: the horizontal row is a
* ranked answer, while the catalogue browser can currently page only media types and
* genres. Labelling an unfiltered library as "all Pixar" would be worse than omitting the
* action until that filter exists.
*/
internal fun homeRowBrowseTarget(row: HomeBrowseRow): HomeRowBrowseTarget? {
if (row.id == "latest-movies") {
return HomeRowBrowseTarget(
categoryId = ALL_MEDIA_CATEGORY_ID,
itemType = "Movie",
actionLabel = "View All",
)
}
val itemType = when {
row.id.startsWith("curated:movies:genre:") -> "Movie"
row.id.startsWith("curated:shows:genre:") -> "Series"
row.id.startsWith("curated:") && row.id.endsWith("-shows") -> "Series"
else -> return null
}
val rowLabel = row.title
.removeSuffix(" TV Shows")
.removeSuffix(" Shows")
.removeSuffix(" Movies")
.trim()
val normalisedLabel = rowLabel.normalisedBrowseLabel()
val category = genreCategoryTabs(itemType).firstOrNull { candidate ->
candidate.id != ALL_MEDIA_CATEGORY_ID && (
candidate.label.normalisedBrowseLabel() == normalisedLabel ||
candidate.genres.any { it.normalisedBrowseLabel() == normalisedLabel }
)
} ?: return null
return HomeRowBrowseTarget(
categoryId = category.id,
itemType = itemType,
actionLabel = "View All ${category.label}",
)
}
private fun String.normalisedBrowseLabel(): String =
lowercase().filter(Char::isLetterOrDigit)
@@ -35,6 +35,9 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -85,131 +88,40 @@ internal fun RequestCard(
/** The trailing affordance, when pressing the card would do something. */ /** The trailing affordance, when pressing the card would do something. */
action: RequestCardAction? = null, action: RequestCardAction? = null,
) { ) {
val shape = RoundedCornerShape(MembyCardCorner) MediaResultCard(
FocusScaleContainer( title = title,
subtitle = subtitle,
detail = detail,
artworkUrl = artworkUrl,
detailColour = requestToneColour(status),
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail) contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail)
.filter(String::isNotBlank) .filter(String::isNotBlank)
.joinToString(", "), .joinToString(", "),
modifier = modifier.fillMaxWidth(), modifier = modifier,
) { focused -> artworkTopStartContent = if (status != RequestStatus.REQUESTABLE) {
Row( { RequestStatusBadge(status, requestStatusChipLabel(status, statusLabel, progress)) }
Modifier } else {
.fillMaxWidth() null
.height(118.dp) },
.clip(shape) artworkBottomStartContent = {
.background(MembySurfaceRaised.copy(alpha = 0.72f)) MediaResultTypeGlyph(isSeries = mediaType.equals("series", ignoreCase = true))
.border( },
if (focused) 2.dp else 1.dp, bodyContent = requestProgressFraction(status, progress)?.let { fraction ->
if (focused) Color.White else Color.White.copy(alpha = 0.07f), {
shape, Spacer(Modifier.height(7.dp))
), RequestProgressBar(fraction = fraction, colour = requestToneColour(status))
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) {
// The monogram sits behind the artwork rather than instead of it, so nothing
// has to decide in advance whether a poster will arrive — the cast panel's
// rule. Radarr and Sonarr posters are remote URLs and often slow.
Box(
Modifier.fillMaxSize().background(
Brush.linearGradient(
listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised),
),
),
contentAlignment = Alignment.Center,
) {
Text(
title.trim().firstOrNull()?.uppercase().orEmpty(),
color = Color.White.copy(alpha = 0.16f),
fontSize = 52.sp,
fontWeight = FontWeight.Black,
)
}
if (!artworkUrl.isNullOrBlank()) {
AsyncImage(
model = artworkUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised),
),
),
)
// A requestable title wears no badge. The trailing affordance already says
// "Request" in the accent, and a second grey chip saying the same word is
// the one thing on the card that is not a *state* — badges here mean what
// has become of something, and this one has had nothing become of it yet.
if (status != RequestStatus.REQUESTABLE) {
RequestStatusBadge(
status = status,
label = requestStatusChipLabel(status, statusLabel, progress),
modifier = Modifier.align(Alignment.TopStart).padding(9.dp),
)
}
MediaTypeGlyph(
mediaType = mediaType,
modifier = Modifier.align(Alignment.BottomStart).padding(9.dp),
)
}
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp),
verticalArrangement = Arrangement.Center,
) {
Text(
title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle.isNotBlank()) {
Spacer(Modifier.height(5.dp))
Text(
subtitle,
color = Color.White.copy(alpha = 0.82f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// The bar sits above the sentence rather than under it, so the quiet line
// stays last on every card whether or not there is a bar — a card with no
// measurable download and one with a finished one must read as the same
// shape. requestProgressFraction is what decides there is one at all: only
// moving bytes have a denominator, and a bar standing somewhere arbitrary
// under a card that is merely being searched for would be the single most
// misleading thing on the page.
requestProgressFraction(status, progress)?.let { fraction ->
Spacer(Modifier.height(7.dp))
RequestProgressBar(fraction = fraction, colour = requestToneColour(status))
}
Spacer(Modifier.height(4.dp))
Text(
detail,
color = requestToneColour(status),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
},
trailingContent = { focused ->
if (busy) { if (busy) {
RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused) RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused)
} else if (action != null) { } else if (action != null) {
RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused) RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused)
} }
} },
} )
} }
/** The trailing affordance on a card: what the centre button would do. */ /** The trailing affordance on a card: what the centre button would do. */
@@ -337,24 +249,6 @@ internal fun requestToneColour(status: String): Color = when (requestStatusTone(
* series takes weeks to arrive and a film does not but it is not worth a second text * series takes weeks to arrive and a film does not but it is not worth a second text
* badge competing with the state. * badge competing with the state.
*/ */
@Composable
private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
val icon = if (mediaType == "series") MembyIcon.Tv.mark else MembyIcon.Movie.mark
Box(
modifier
.size(24.dp)
.background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = if (mediaType == "series") "Series" else "Film",
tint = Color.White.copy(alpha = 0.82f),
modifier = Modifier.size(13.dp),
)
}
}
/** /**
* The card's shape with nothing in it, shown while the first list is loading. * The card's shape with nothing in it, shown while the first list is loading.
* *
@@ -364,35 +258,5 @@ private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
*/ */
@Composable @Composable
internal fun RequestCardSkeleton(modifier: Modifier = Modifier) { internal fun RequestCardSkeleton(modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(MembyCardCorner) MediaResultCardSkeleton(modifier)
Row(
modifier
.fillMaxWidth()
.height(118.dp)
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.34f))
.border(1.dp, Color.White.copy(alpha = 0.05f), shape),
) {
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f)))
Column(
Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp),
verticalArrangement = Arrangement.Center,
) {
SkeletonBar(widthFraction = 0.52f, height = 15.dp)
Spacer(Modifier.height(9.dp))
SkeletonBar(widthFraction = 0.32f, height = 11.dp)
Spacer(Modifier.height(7.dp))
SkeletonBar(widthFraction = 0.22f, height = 9.dp)
}
}
}
@Composable
private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) {
Box(
Modifier
.fillMaxWidth(widthFraction)
.height(height)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)),
)
} }
@@ -37,11 +37,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -69,7 +66,6 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -83,13 +79,13 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.MembyChoiceChip import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.PosterGridCard import com.ponzischeme89.memby.ui.search.components.SearchGenres
import com.ponzischeme89.memby.ui.search.components.SearchResults
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
@@ -127,18 +123,6 @@ private val KeyboardRows = listOf(
/** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */ /** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */
private const val KEYBOARD_PANE_FRACTION = 0.35f private const val KEYBOARD_PANE_FRACTION = 0.35f
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
private const val PREFETCHED_POSTERS = 8
/**
* How far from the end of a genre the next page is asked for, in rows.
*
* Two rows rather than one: a D-pad walks a row at a time and the request has to be in
* flight before the viewer arrives at the bottom, or the scroll stops dead and the shelf
* reads as having ended.
*/
private const val LOAD_MORE_ROWS_AHEAD = 2
/** /**
* Full-screen search: keyboard on the left, results on the right, updating as you type. * Full-screen search: keyboard on the left, results on the right, updating as you type.
* *
@@ -168,7 +152,8 @@ fun SearchScreen(
LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) } LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
val resultsEntry = remember { FocusRequester() } val genresEntry = remember { FocusRequester() }
val searchResultsEntry = remember { FocusRequester() }
// The rail and the screen agree on one entry target: Search opens on the keyboard, // The rail and the screen agree on one entry target: Search opens on the keyboard,
// just as browse destinations open on their primary content action. // just as browse destinations open on their primary content action.
val keyboardEntry = contentFocusRequester val keyboardEntry = contentFocusRequester
@@ -191,23 +176,21 @@ fun SearchScreen(
} }
val hasResultsTarget = when { val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> discoveryItems.isNotEmpty() || state.genreSuggestions.isNotEmpty() state.isDiscovery -> state.genreSuggestions.isNotEmpty()
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() || else -> state.results.any { it.isMovie || it.isSeries } || state.requestCandidates.isNotEmpty() ||
(state.requestsAvailable && shouldSearch(state.query)) (state.requestsAvailable && shouldSearch(state.query))
} }
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
// A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with // The genre strip remains above its shelf. Once a first result exists the list takes
// it — so the focus it was holding belongs to nothing unless something takes it. The // focus, which is where the viewer is already looking; an empty genre hands the remote
// grid claims it as soon as there is a card to land on, which is where the viewer is // back to the keyboard rather than leaving the television on a dead end.
// already looking; a genre that came back empty or failed hands the remote back to the
// keyboard rather than leaving a television with nothing focused at all.
LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) { LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) {
if (state.genre == null) return@LaunchedEffect if (state.genre == null) return@LaunchedEffect
when { when {
state.results.isNotEmpty() || state.errorMessage != null -> state.results.any { it.isMovie || it.isSeries } || state.errorMessage != null ->
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true if (runCatching { searchResultsEntry.requestFocus() }.isSuccess) focusInResults = true
!state.isLoading -> runCatching { keyboardReturn.requestFocus() } !state.isLoading -> runCatching { keyboardReturn.requestFocus() }
// Still loading: the branch above takes it the moment the first page lands. // Still loading: the branch above takes it the moment the first page lands.
else -> Unit else -> Unit
@@ -216,10 +199,9 @@ fun SearchScreen(
LaunchedEffect(state.genre, restoreGenreChipFocus) { LaunchedEffect(state.genre, restoreGenreChipFocus) {
if (state.genre == null && restoreGenreChipFocus) { if (state.genre == null && restoreGenreChipFocus) {
// clearGenre remounts discovery and its genre chips. Wait for the first chip's // The strip stays mounted while a genre is open, so Back can restore its stable
// focus node before handing the remote back to where this level was opened. // entry target as soon as the result list has left composition.
kotlinx.coroutines.delay(16L) if (runCatching { genresEntry.requestFocus() }.isSuccess) focusInResults = true
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
restoreGenreChipFocus = false restoreGenreChipFocus = false
} }
} }
@@ -267,7 +249,7 @@ fun SearchScreen(
SearchPane( SearchPane(
state = state, state = state,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry, resultsEntry = if (state.genreSuggestions.isNotEmpty()) genresEntry else searchResultsEntry,
keyboardEntry = keyboardEntry, keyboardEntry = keyboardEntry,
keyboardReturn = keyboardReturn, keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex, lastKeyIndex = lastKeyIndex,
@@ -285,27 +267,61 @@ fun SearchScreen(
.fillMaxWidth(KEYBOARD_PANE_FRACTION) .fillMaxWidth(KEYBOARD_PANE_FRACTION)
.fillMaxHeight(), .fillMaxHeight(),
) )
ResultsPane( Column(
state = state, Modifier
resultsEntry = resultsEntry, .weight(1f)
keyboardReturn = keyboardReturn, .fillMaxHeight()
returnFocusItemId = returnFocusItemId, .padding(start = 28.dp, end = 32.dp, top = 24.dp, bottom = 12.dp),
returnFocusRequester = returnFocusRequester, ) {
discoveryItems = discoveryItems, SearchGenres(
onItemFocused = { item -> genres = state.genreSuggestions,
focusInResults = true entryFocusRequester = genresEntry,
onContentFocused() keyboardReturnFocusRequester = keyboardReturn,
onItemFocused(item) resultsFocusRequester = searchResultsEntry,
}, resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } ||
onItemSelected = onItemSelected, (state.errorMessage != null && state.results.isEmpty()) ||
onRetry = viewModel::retry, (state.requestsAvailable && shouldSearch(state.query)),
onRequest = viewModel::request, onFocused = {
onShowRequests = viewModel::showRequests, focusInResults = true
onGenreSelected = viewModel::onGenreSelected, onContentFocused()
onBackFromGenre = closeGenre, },
onLoadMore = viewModel::loadMore, onSelected = viewModel::onGenreSelected,
modifier = Modifier.fillMaxHeight(), )
) Spacer(Modifier.height(14.dp))
if (state.requestMode) {
RequestOptions(
state = state,
resultsEntry = searchResultsEntry,
keyboardReturn = keyboardReturn,
onRequest = viewModel::request,
modifier = Modifier.weight(1f),
)
} else {
SearchResults(
state = state,
entryFocusRequester = searchResultsEntry,
genresFocusRequester = genresEntry,
genresPresent = state.genreSuggestions.isNotEmpty(),
keyboardReturnFocusRequester = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
artworkUrlFor = { item ->
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
},
onItemFocused = { item ->
focusInResults = true
onContentFocused()
onItemFocused(item)
},
onItemSelected = onItemSelected,
onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore,
modifier = Modifier.weight(1f),
)
}
}
} }
} }
@@ -558,7 +574,7 @@ internal fun TvKeyboard(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
// Explicit edges. Left of the first column is the navigation // Explicit edges. Left of the first column is the navigation
// rail, right of the last is the results grid: focus can // rail, right of the last enters Search's discovery/results pane: focus can
// always get out of this pane, and never falls off it. // always get out of this pane, and never falls off it.
.focusProperties { .focusProperties {
if (columnIndex == 0) left = navigationFocusRequester if (columnIndex == 0) left = navigationFocusRequester
@@ -791,293 +807,6 @@ private fun LoadingDot() {
) )
} }
@Composable
private fun ResultsPane(
state: SearchUiState,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
discoveryItems: List<BaseItem>,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRetry: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onShowRequests: () -> Unit,
onGenreSelected: (String) -> Unit,
onBackFromGenre: () -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp),
) {
val columns = if (maxWidth > 760.dp) 5 else 4
val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns)
.coerceIn(120.dp, 200.dp)
// Discovery, results, error and "no matches" all share this pane. Only the
// heading and the item source change, so the grid never unmounts and remounts.
val showingDiscovery = state.isDiscovery
val items = if (showingDiscovery) discoveryItems else state.results
Column(Modifier.fillMaxSize()) {
ResultsHeading(
state = state,
showingDiscovery = showingDiscovery,
onBackFromGenre = onBackFromGenre,
onShowRequests = onShowRequests,
requestFocusRequester = resultsEntry,
keyboardReturn = keyboardReturn,
)
val genres = state.genreSuggestions
if (showingDiscovery && genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
GenreTiles(
genres = genres,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
// A genre opens the shelf of titles that are in it: running its name
// through search matched a film called Drama and missed most of the
// drama.
onSelected = onGenreSelected,
)
}
Spacer(Modifier.height(12.dp))
when {
state.errorMessage != null && state.results.isEmpty() -> SearchError(
message = state.errorMessage,
onRetry = onRetry,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
)
!showingDiscovery && state.requestMode ->
RequestOptions(
state = state,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onRequest = onRequest,
modifier = Modifier.weight(1f),
)
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
else -> ResultsGrid(
items = items,
columns = columns,
cardWidth = cardWidth,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
returnFocusItemId = returnFocusItemId,
returnFocusRequester = returnFocusRequester,
onItemFocused = onItemFocused,
onItemSelected = onItemSelected,
// Only a genre pages. A search is one response, and asking the grid to
// watch for the end of a list that has no more behind it is a scroll
// listener running for nothing.
paging = state.genre != null && (state.canLoadMore || state.isLoadingMore),
loadingMore = state.isLoadingMore,
pagingErrorMessage = state.pagingErrorMessage,
onRetryPage = onRetry,
onLoadMore = onLoadMore,
)
}
}
}
}
@Composable
private fun ResultsHeading(
state: SearchUiState,
showingDiscovery: Boolean,
onBackFromGenre: () -> Unit,
onShowRequests: () -> Unit,
requestFocusRequester: FocusRequester,
keyboardReturn: FocusRequester,
) {
val title = when {
showingDiscovery -> "Browse your library"
// A genre says what it is. It was never searched for, so calling it a search result
// would misdescribe both where the titles came from and how to get out of it.
state.genre != null -> state.genre
state.isEmptyResult -> "Search results — no matches"
else -> "Search results for “${state.query.trim()}"
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.genre != null) {
FocusScaleContainer(
onFocused = {},
onClick = onBackFromGenre,
contentDescription = "Back to search",
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)),
) { focused ->
Box(
modifier = Modifier
.fillMaxSize()
.background(if (focused) Color.White else KeyIdle),
contentAlignment = Alignment.Center,
) {
Icon(
MembyIcon.ArrowBack.mark,
contentDescription = null,
tint = if (focused) KeyLabelFocused else Heading,
modifier = Modifier.size(21.dp),
)
}
}
Spacer(Modifier.width(12.dp))
}
Text(
title,
color = Heading,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// The count belongs to a search, where it is the whole answer. On a genre it would
// be the number of cards fetched so far, which grows as the viewer scrolls and
// describes the paging rather than the library.
if (state.genre == null && !showingDiscovery && state.results.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
}
if (state.requestsAvailable && state.genre == null && !showingDiscovery && !state.requestMode) {
Spacer(Modifier.width(14.dp))
MembyChoiceChip(
label = "Request",
selected = false,
onClick = onShowRequests,
modifier = Modifier
.then(if (state.results.isEmpty()) Modifier.focusRequester(requestFocusRequester) else Modifier)
.focusProperties { left = keyboardReturn },
)
}
}
}
@Composable
private fun ResultsGrid(
items: List<BaseItem>,
columns: Int,
cardWidth: Dp,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
paging: Boolean = false,
loadingMore: Boolean = false,
pagingErrorMessage: String? = null,
onRetryPage: () -> Unit = {},
onLoadMore: () -> Unit = {},
) {
val context = LocalContext.current
val density = LocalDensity.current
val gridState = rememberLazyGridState()
val currentOnLoadMore by rememberUpdatedState(onLoadMore)
// Infinite scroll. Read in a snapshotFlow rather than from the composable body: the
// last visible index changes on every frame of a scroll, and reading it up here would
// recompose the whole grid the entire way down a genre. The next page is asked for a
// row early — the request has to be in flight before the viewer arrives at the end, or
// the scroll stops dead while they wait for it.
if (paging) {
LaunchedEffect(gridState, items.size, columns) {
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
.distinctUntilChanged()
.collect { last ->
if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) currentOnLoadMore()
}
}
}
// Warm the first screenful so the grid does not fill in card by card. Keyed on the
// ids rather than the list, so an unchanged result set never re-fetches.
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
LaunchedEffect(prefetchKey, cardWidth) {
val repo = ServiceLocator.repository
val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720)
items.take(PREFETCHED_POSTERS).forEach { item ->
val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
?: return@forEach
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.size(widthPx, (widthPx * 3f / 2f).toInt())
.allowHardware(true)
.crossfade(false)
.build(),
)
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(CARD_SPACING),
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier
.fillMaxSize(),
) {
// itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would
// run a deep equals per card per composition on the app's hottest new screen.
itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item ->
PosterGridCard(
item = item,
width = cardWidth,
onFocused = { onItemFocused(item) },
onClick = { onItemSelected(item) },
onLongClick = { onItemSelected(item) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
// Leftmost column goes back to the keyboard rather than nowhere.
.focusProperties {
if (index % columns == 0) left = keyboardReturn
},
)
}
if (loadingMore) {
// A full-width row rather than a card-shaped placeholder: a skeleton card is
// something a remote tries to focus, and there is nothing there to open.
item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging") {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
LoadingDot()
Spacer(Modifier.width(10.dp))
Text("Loading more", color = Muted, fontSize = 13.sp)
}
}
}
if (pagingErrorMessage != null) {
item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging-error") {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Couldnt load more titles.", color = Muted, fontSize = 13.sp)
Spacer(Modifier.width(10.dp))
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage)
}
}
}
}
}
@Composable @Composable
private fun RequestOptions( private fun RequestOptions(
state: SearchUiState, state: SearchUiState,
@@ -1346,118 +1075,6 @@ internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
} }
} }
private val GenreColors = listOf(
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
)
private fun genreIcon(label: String): ImageVector = when {
label.contains("comedy", true) -> MembyIcon.Drama.mark
label.contains("music", true) -> MembyIcon.LiveTv.mark
label.contains("children", true) || label.contains("family", true) -> MembyIcon.Happy.mark
label.contains("sport", true) -> MembyIcon.PlayCircle.mark
label.contains("document", true) -> MembyIcon.Movie.mark
else -> MembyIcon.Sparkle.mark
}
@Composable
private fun GenreTiles(
genres: List<String>,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onSelected: (String) -> Unit,
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowItemsIndexed(genres.take(6), key = { _, item -> item }) { index, genre ->
val color = GenreColors[index % GenreColors.size]
FocusScaleContainer(
onFocused = {},
onClick = { onSelected(genre) },
contentDescription = "Search the $genre genre",
modifier = Modifier
.width(126.dp)
.height(72.dp)
.clip(RoundedCornerShape(10.dp))
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index == 0) left = keyboardReturn },
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(if (focused) color.copy(alpha = 0.95f) else color),
) {
Icon(
genreIcon(genre),
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp)
.size(30.dp),
)
Text(
genre,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
)
}
}
}
}
}
@Composable
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
val message = when {
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
// A genre was never searched for, and saying so while a shelf opens is the one
// moment the difference is invisible on screen.
state.genre != null && state.isLoading -> "Loading ${state.genre}"
state.genre != null -> "Nothing in this library is tagged ${state.genre}."
state.isLoading -> "Searching…"
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
}
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
}
@Composable
private fun SearchError(
message: String,
onRetry: () -> Unit,
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
) {
Column(Modifier.padding(top = 36.dp)) {
Text(message, color = Heading, fontSize = 18.sp)
Spacer(Modifier.height(14.dp))
FocusScaleContainer(
onFocused = {},
onClick = onRetry,
contentDescription = "Try the search again",
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.focusRequester(resultsEntry)
.focusProperties { left = keyboardReturn },
) { focused ->
Text(
"Try again",
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 22.dp, vertical = 11.dp),
)
}
}
}
private val CARD_SPACING = 16.dp
private const val KEYBOARD_COLUMNS = 6 private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36 private const val ACTION_ROW_INDEX = 36
@@ -36,13 +36,13 @@ data class SearchUiState(
* clearing something the viewer never typed. * clearing something the viewer never typed.
*/ */
val genre: String? = null, val genre: String? = null,
/** A further page is on its way. The grid keeps what it has and adds a footer. */ /** A further page is on its way. The result list keeps what it has and adds a footer. */
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
/** /**
* How far into the genre the shelf has read, counted the way the *backend* counts it. * How far into the genre the shelf has read, counted the way the *backend* counts it.
* *
* Deliberately not `results.size`. A duplicate arriving across a page boundary is * Deliberately not `results.size`. A duplicate arriving across a page boundary is
* dropped on the way in the grid is keyed by item id, so a repeat would be a crash * dropped on the way in the result list is keyed by item id, so a repeat would be a crash
* rather than a doubled poster and if the offset were then derived from the length * rather than a doubled poster and if the offset were then derived from the length
* of the list, the next page would be requested from before where the last one ended * of the list, the next page would be requested from before where the last one ended
* and would return the same cards again, for ever. * and would return the same cards again, for ever.
@@ -216,8 +216,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
/** /**
* The grid is near the end of what it holds. Ignored unless there is a genre open, more * The result list is near the end of what it holds. Ignored unless there is a genre open,
* of it to fetch and nothing already in flight the grid asks on every scroll, and it * more of it to fetch and nothing already in flight the list asks on every scroll, and it
* is cheaper to refuse here than to make the screen keep track. * is cheaper to refuse here than to make the screen keep track.
*/ */
fun loadMore() { fun loadMore() {
@@ -340,7 +340,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
* Appending by [GenrePage.offset] rather than trusting the order of arrival is what * Appending by [GenrePage.offset] rather than trusting the order of arrival is what
* makes a slow page harmless: only a page that starts where the shelf currently ends is * makes a slow page harmless: only a page that starts where the shelf currently ends is
* taken, so a response for an offset the viewer has already scrolled past or one from * taken, so a response for an offset the viewer has already scrolled past or one from
* a genre they have left is dropped rather than pasted into the middle of the grid. * a genre they have left is dropped rather than pasted into the middle of the list.
*/ */
private suspend fun loadGenrePage(genre: String, offset: Int) { private suspend fun loadGenrePage(genre: String, offset: Int) {
runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) } runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
@@ -350,7 +350,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// A paging boundary is the one place a backend realistically repeats a // A paging boundary is the one place a backend realistically repeats a
// card: the shelf is ordered by premiere date and sort name precisely // card: the shelf is ordered by premiere date and sort name precisely
// because two titles sharing a date could otherwise swap places between // because two titles sharing a date could otherwise swap places between
// two requests. If one slips through anyway, the grid is keyed by item // two requests. If one slips through anyway, the result list is keyed by item
// id and a repeat is a crash, not a duplicate poster — so the page is // id and a repeat is a crash, not a duplicate poster — so the page is
// appended minus anything already on the shelf, and how far the shelf // appended minus anything already on the shelf, and how far the shelf
// has read is counted in what the backend sent rather than in what // has read is counted in what the backend sent rather than in what
@@ -435,7 +435,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
// The gateway answers from the imported library and falls back to Emby // The gateway answers from the imported library and falls back to Emby
// before the first import has finished, so one title reaching the pane by // before the first import has finished, so one title reaching the pane by
// both routes is a shape this search genuinely has. The grid is keyed by // both routes is a shape this search genuinely has. The result list is keyed by
// item id, where that is a crash rather than a repeated poster. // item id, where that is a crash rather than a repeated poster.
val items = found.distinctItems() val items = found.distinctItems()
// Gateway payloads carry the backend ranker's score. Preserve that // Gateway payloads carry the backend ranker's score. Preserve that
@@ -0,0 +1,117 @@
package com.ponzischeme89.memby.ui.search.components
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */
@Composable
internal fun SearchGenres(
genres: List<String>,
entryFocusRequester: FocusRequester,
keyboardReturnFocusRequester: FocusRequester,
resultsFocusRequester: FocusRequester,
resultsHaveFocusTarget: Boolean,
onFocused: () -> Unit,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (genres.isEmpty()) return
Column(modifier) {
Text(
"Genres ",
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre ->
val colour = GenreColours[index % GenreColours.size]
FocusScaleContainer(
onFocused = onFocused,
onClick = { onSelected(genre) },
contentDescription = "Browse the $genre genre",
modifier = Modifier
.width(126.dp)
.height(64.dp)
.clip(GenreShape)
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.focusProperties {
if (index == 0) left = keyboardReturnFocusRequester
if (resultsHaveFocusTarget) down = resultsFocusRequester
},
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(if (focused) colour.copy(alpha = 0.98f) else colour)
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
GenreShape,
)
.padding(horizontal = 11.dp, vertical = 9.dp),
) {
Text(
genre,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.CenterStart).padding(end = 18.dp),
)
Text(
"",
color = Color.White.copy(alpha = if (focused) 1f else 0.72f),
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.align(Alignment.CenterEnd),
)
}
}
}
}
}
}
private val GenreShape = RoundedCornerShape(10.dp)
/** Dark, desaturated counterparts of Search's established genre palette. */
private val GenreColours = listOf(
Color(0xFF4A332C),
Color(0xFF303B50),
Color(0xFF3D334B),
Color(0xFF263F3B),
Color(0xFF4A3A29),
Color(0xFF49303A),
)
@@ -0,0 +1,257 @@
package com.ponzischeme89.memby.ui.search.components
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.components.media.MediaResultCard
import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton
import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph
import com.ponzischeme89.memby.ui.search.SearchUiState
import com.ponzischeme89.memby.ui.search.shouldSearch
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import kotlinx.coroutines.flow.distinctUntilChanged
/** Search-owned result state rendered through the same media row as My Requests. */
@Composable
internal fun SearchResults(
state: SearchUiState,
entryFocusRequester: FocusRequester,
genresFocusRequester: FocusRequester,
genresPresent: Boolean,
keyboardReturnFocusRequester: FocusRequester,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
artworkUrlFor: (BaseItem) -> String?,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
onRetry: () -> Unit,
onShowRequests: () -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val items = state.results.filter { it.isMovie || it.isSeries }
val listState = rememberLazyListState()
val currentOnLoadMore by rememberUpdatedState(onLoadMore)
if (state.genre != null && (state.canLoadMore || state.isLoadingMore)) {
LaunchedEffect(listState, items.size) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
.distinctUntilChanged()
.collect { last ->
if (last >= items.lastIndex - LOAD_MORE_ITEMS_AHEAD) currentOnLoadMore()
}
}
}
Column(modifier) {
SearchResultsHeading(
state = state,
resultCount = items.size,
onShowRequests = onShowRequests,
requestFocusRequester = entryFocusRequester,
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
)
Spacer(Modifier.height(8.dp))
when {
state.isLoading && items.isEmpty() -> Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
repeat(3) { MediaResultCardSkeleton() }
}
state.errorMessage != null && items.isEmpty() -> SearchResultMessage(
message = state.errorMessage,
action = "Try again",
onAction = onRetry,
actionFocusRequester = entryFocusRequester,
genresFocusRequester = genresFocusRequester.takeIf { genresPresent },
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
)
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
else -> LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
MediaResultCard(
title = item.name,
subtitle = searchResultSubtitle(item),
detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" },
artworkUrl = artworkUrlFor(item),
contentDescription = listOf(item.name, searchResultSubtitle(item))
.filter(String::isNotBlank)
.joinToString(", "),
onFocused = { onItemFocused(item) },
onClick = { onItemSelected(item) },
artworkBottomStartContent = {
MediaResultTypeGlyph(isSeries = item.isSeries)
},
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.then(
if (item.id == returnFocusItemId) {
Modifier.focusRequester(returnFocusRequester)
} else {
Modifier
},
)
.focusProperties {
left = keyboardReturnFocusRequester
if (index == 0 && genresPresent) up = genresFocusRequester
},
)
}
if (state.isLoadingMore) {
item(key = "search-loading-more") {
Text(
"Loading more",
color = MembyMutedText,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
)
}
}
state.pagingErrorMessage?.let {
item(key = "search-paging-error") {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Couldnt load more titles.", color = MembyMutedText, fontSize = 13.sp)
Spacer(Modifier.width(10.dp))
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry)
}
}
}
}
}
}
}
@Composable
private fun SearchResultsHeading(
state: SearchUiState,
resultCount: Int,
onShowRequests: () -> Unit,
requestFocusRequester: FocusRequester,
keyboardReturnFocusRequester: FocusRequester,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
when {
state.genre != null -> state.genre
shouldSearch(state.query) -> "Search results for “${state.query.trim()}"
else -> "Search results"
},
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (resultCount > 0 && !state.isLoading) {
Spacer(Modifier.width(9.dp))
Text(resultCount.toString(), color = MembyMutedText, fontSize = 14.sp)
}
if (state.isLoading && resultCount > 0) {
Spacer(Modifier.width(9.dp))
Text("Searching…", color = MembyAccent, fontSize = 12.sp)
}
if (
state.requestsAvailable && state.genre == null && shouldSearch(state.query) &&
!state.requestMode && state.errorMessage == null
) {
Spacer(Modifier.width(12.dp))
MembyChoiceChip(
label = "Request",
selected = false,
onClick = onShowRequests,
modifier = Modifier
.then(if (resultCount == 0) Modifier.focusRequester(requestFocusRequester) else Modifier)
.focusProperties { left = keyboardReturnFocusRequester },
)
}
}
}
@Composable
private fun SearchResultMessage(
message: String,
action: String? = null,
onAction: () -> Unit = {},
actionFocusRequester: FocusRequester? = null,
genresFocusRequester: FocusRequester? = null,
keyboardReturnFocusRequester: FocusRequester? = null,
) {
Column(Modifier.fillMaxSize().padding(top = 22.dp)) {
Text(message, color = MembyMutedText, fontSize = 15.sp)
if (action != null) {
Spacer(Modifier.height(12.dp))
MembyChoiceChip(
label = action,
selected = false,
onClick = onAction,
modifier = Modifier
.then(actionFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
.focusProperties {
genresFocusRequester?.let { up = it }
keyboardReturnFocusRequester?.let { left = it }
},
)
}
}
}
private fun emptyMessage(state: SearchUiState): String = when {
state.genre != null -> "Nothing in this library is tagged ${state.genre}."
shouldSearch(state.query) && state.hasSearched ->
"Nothing in this library matches that. Try fewer letters, or a different spelling."
else -> "Type a couple of letters to search movies and TV series."
}
private fun searchResultSubtitle(item: BaseItem): String = listOfNotNull(
item.productionYear?.toString(),
when {
item.isSeries -> "Series"
item.isMovie -> "Film"
else -> null
},
).joinToString(FactSeparator)
private const val LOAD_MORE_ITEMS_AHEAD = 4
@@ -0,0 +1,95 @@
package com.ponzischeme89.memby.tvhome
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TvHomeProgramTest {
@Test
fun `partially watched movie becomes a continue programme`() {
val programme = tvHomeProgram(
item = BaseItem(
id = "movie-1",
name = "A Film",
type = "Movie",
runTimeTicks = 7_200_000_000L,
userData = UserItemData(playbackPositionTicks = 1_800_000_000L),
),
profileKey = "viewer",
artworkUrl = "https://example.test/backdrop.jpg",
nowMs = 123L,
)
requireNotNull(programme)
assertEquals(TvHomeProgramKind.CONTINUE, programme.kind)
assertEquals(180_000L, programme.positionMs)
assertEquals(720_000L, programme.durationMs)
assertEquals("memby:viewer:movie-1", programme.providerId)
}
@Test
fun `unwatched next episode carries series metadata`() {
val programme = tvHomeProgram(
item = BaseItem(
id = "episode-4",
name = "Fourth",
type = "Episode",
seriesName = "The Show",
parentIndexNumber = 2,
indexNumber = 4,
),
profileKey = "viewer",
artworkUrl = null,
nowMs = 123L,
)
requireNotNull(programme)
assertEquals(TvHomeProgramKind.NEXT, programme.kind)
assertEquals("The Show", programme.title)
assertEquals("Fourth", programme.episodeTitle)
assertEquals(2, programme.seasonNumber)
assertEquals(4, programme.episodeNumber)
}
@Test
fun `completed watched and unsupported items are omitted`() {
assertNull(tvHomeProgram(item("Movie", played = true), "p", null, 1L))
assertNull(
tvHomeProgram(
item("Movie", positionTicks = 9_500_000L, runtimeTicks = 10_000_000L),
"p",
null,
1L,
),
)
assertNull(tvHomeProgram(item("Series"), "p", null, 1L))
}
@Test
fun `profile key is stable without exposing account details`() {
val first = tvHomeProfileKey("https://emby.test/", "user-1", "viewer-1")
val same = tvHomeProfileKey("https://emby.test", "user-1", "viewer-1")
val other = tvHomeProfileKey("https://emby.test", "user-2", "viewer-1")
assertEquals(first, same)
assertNotEquals(first, other)
assertTrue("user-1" !in first)
assertTrue("emby" !in first)
}
private fun item(
type: String,
played: Boolean = false,
positionTicks: Long = 0L,
runtimeTicks: Long? = null,
) = BaseItem(
id = "id",
name = "Name",
type = type,
runTimeTicks = runtimeTicks,
userData = UserItemData(played = played, playbackPositionTicks = positionTicks),
)
}
@@ -0,0 +1,61 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HomeRowBrowseTest {
@Test
fun `recent releases opens the complete movie catalogue`() {
val target = homeRowBrowseTarget(row("latest-movies", "Recent New Releases", MediaRowKind.MOVIES))
assertEquals("all", target?.categoryId)
assertEquals("Movie", target?.itemType)
assertEquals("View All", target?.actionLabel)
}
@Test
fun `movie and show genre rows open their matching paged catalogue`() {
val crime = homeRowBrowseTarget(
row("curated:movies:genre:crime", "Crime Movies", MediaRowKind.MOVIES),
)
val drama = homeRowBrowseTarget(
row("curated:shows:genre:drama", "Drama Shows", MediaRowKind.SHOWS),
)
assertEquals("crime", crime?.categoryId)
assertEquals("Movie", crime?.itemType)
assertEquals("View All Crime", crime?.actionLabel)
assertEquals("drama", drama?.categoryId)
assertEquals("Series", drama?.itemType)
assertEquals("View All Drama", drama?.actionLabel)
}
@Test
fun `legacy show genres remain browsable`() {
val target = homeRowBrowseTarget(
row("curated:comedy-shows", "Comedy TV Shows", MediaRowKind.SHOWS),
)
assertEquals("comedy", target?.categoryId)
assertEquals("Series", target?.itemType)
}
@Test
fun `ranked and studio rows do not claim an unrelated complete catalogue`() {
assertNull(homeRowBrowseTarget(row("recommended", "Recommended", MediaRowKind.MOVIES)))
assertNull(
homeRowBrowseTarget(
row("curated:movies:studio:pixar", "More from Pixar", MediaRowKind.MOVIES),
),
)
}
private fun row(id: String, title: String, kind: MediaRowKind) = HomeBrowseRow(
id = id,
title = title,
items = emptyList(),
kind = kind,
emptyMessage = "",
)
}
+1 -1
View File
@@ -1 +1 @@
0.1.63 0.1.64