0.2.98
This commit is contained in:
@@ -0,0 +1 @@
|
||||
e76e2be01f9477bf3dae4d4f5582546f
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -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 TV’s 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
|
||||
- Improved: App design / layout tweaks.
|
||||
- Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother.
|
||||
- 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
|
||||
- Improved: Loading and performance of the continue watching row.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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)` →
|
||||
`trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and
|
||||
`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.
|
||||
- **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
|
||||
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.
|
||||
- **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
|
||||
`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.
|
||||
- **Something has to take the focus the chip was holding**, because opening a shelf unmounts
|
||||
the whole discovery pane. The grid claims it when the first page lands, and an empty or
|
||||
failed genre hands it back to the keyboard rather than leaving a television with nothing
|
||||
focused.
|
||||
- **Genre and result focus targets are separate.** The genre strip stays mounted above the
|
||||
results; a genre result claims its own requester when the first page lands, Back restores
|
||||
the strip, and an empty or failed genre hands focus back deterministically rather than
|
||||
leaving a television with nothing focused.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
used* (a `FocusRequester` attached to whichever key that is), and the grid has a
|
||||
`focusRestorer`. Back moves results → keyboard → genre → leave, one step per press.
|
||||
rightmost goes to Genres, Down enters Search Results, and every result goes back to the
|
||||
*last key used* (a `FocusRequester` attached to whichever key that is). Back closes an open
|
||||
genre shelf before moving results → keyboard → leave, one step per press.
|
||||
Physical keyboards and phone-remote apps feed the same state through one
|
||||
`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
|
||||
|
||||
@@ -64,7 +64,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.97"
|
||||
val defaultVersionName = "0.2.98"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -275,6 +275,8 @@ dependencies {
|
||||
|
||||
// Compose for TV
|
||||
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
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Lets a self-update apply without a confirmation screen once Memby is its own
|
||||
@@ -117,6 +119,26 @@
|
||||
android:theme="@style/Theme.Memby.Fullscreen"
|
||||
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).
|
||||
Interactive: select to open the panel, play, or favourite. -->
|
||||
<service
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
||||
import com.ponzischeme89.memby.data.remote.StreamWarmer
|
||||
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateGuard
|
||||
import com.ponzischeme89.memby.tvhome.TvHomeIntegrationService
|
||||
|
||||
/**
|
||||
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
|
||||
@@ -65,6 +66,10 @@ object ServiceLocator {
|
||||
internal lateinit var streamWarmer: StreamWarmer
|
||||
private set
|
||||
|
||||
/** Process-scoped, supplementary bridge to the television launcher. */
|
||||
lateinit var tvHome: TvHomeIntegrationService
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::repository.isInitialized) return
|
||||
// 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,
|
||||
// the screensaver's DreamService — are separate roots with no common owner but this.
|
||||
themeSync = ThemeSync(repository, settings, maintenance.theme)
|
||||
tvHome = TvHomeIntegrationService(context.applicationContext, repository, settings)
|
||||
tvHome.start()
|
||||
remoteConfig.refreshLater()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2612,6 +2612,9 @@ class EmbyRepository internal constructor(
|
||||
// evidence: a process killed between two reports still leaves the ledger describing
|
||||
// a position within ten seconds of the truth.
|
||||
recordLocalResume(session.itemId, positionMs, durationMs)
|
||||
_playbackPositions.tryEmit(
|
||||
PlaybackPosition(session.itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
|
||||
)
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report(
|
||||
"progress",
|
||||
|
||||
@@ -32,6 +32,7 @@ enum class PlaybackEntryPoint(val id: String) {
|
||||
*/
|
||||
DETAIL_PAGE("detail_page"),
|
||||
SCREENSAVER("screensaver"),
|
||||
TV_HOME("tv_home"),
|
||||
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
|
||||
* 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 quickMenuRowId 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) {
|
||||
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
|
||||
}
|
||||
@@ -843,6 +846,7 @@ internal fun HomeScreen(
|
||||
)
|
||||
LaunchedEffect(selectedDestination) {
|
||||
focusedHomeRowId = null
|
||||
rowBrowseTarget = null
|
||||
}
|
||||
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
|
||||
if (
|
||||
@@ -863,6 +867,7 @@ internal fun HomeScreen(
|
||||
showNotifications -> "notifications"
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
rowBrowseTarget != null -> "row_browse"
|
||||
selectedMyShow != null -> "my_show_details"
|
||||
else -> selectedDestination.name.lowercase()
|
||||
}
|
||||
@@ -935,6 +940,7 @@ internal fun HomeScreen(
|
||||
navigationExpanded = it
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
rowBrowseTarget = null
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "select",
|
||||
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||
@@ -1050,6 +1056,53 @@ internal fun HomeScreen(
|
||||
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) {
|
||||
// Remembered, or this list is a fresh instance on every recomposition
|
||||
// and the screen re-derives its genre chips each time.
|
||||
@@ -1511,6 +1564,7 @@ internal fun HomeScreen(
|
||||
row.items.take(8).map { it.id },
|
||||
)
|
||||
}
|
||||
val browseTarget = homeRowBrowseTarget(row)
|
||||
MediaRow(
|
||||
modifier = Modifier,
|
||||
row = row,
|
||||
@@ -1672,6 +1726,24 @@ internal fun HomeScreen(
|
||||
horizontalState = horizontalStates.getOrPut(
|
||||
"${selectedDestination.name}:${row.id}",
|
||||
) { 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.
|
||||
*
|
||||
* 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
|
||||
fun ResumableMediaCard(
|
||||
@@ -152,6 +152,7 @@ fun ResumableMediaCard(
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val artworkUrl = if (portraitArtwork) {
|
||||
@@ -299,6 +300,7 @@ fun ResumableMediaCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
titleSupplement?.invoke(focused)
|
||||
model.episodeLabel?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.ui.components.media
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -7,6 +8,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.ItemRatingsStrip
|
||||
import com.ponzischeme89.memby.ui.ResumableMediaCard
|
||||
import com.ponzischeme89.memby.ui.responsiveRowCardWidth
|
||||
import com.ponzischeme89.memby.ui.toResumableMediaCardModel
|
||||
@@ -55,5 +57,18 @@ fun ContinueWatchingCard(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
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.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -53,11 +55,11 @@ import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
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.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||
@@ -173,11 +175,18 @@ internal fun MediaRow(
|
||||
density: String = "standard",
|
||||
artworkStyle: String = "automatic",
|
||||
horizontalState: LazyListState? = null,
|
||||
viewAllLabel: String? = null,
|
||||
onViewAll: (() -> Unit)? = null,
|
||||
) {
|
||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||
val rowState = horizontalState ?: savedRowState
|
||||
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
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
@@ -205,10 +214,22 @@ internal fun MediaRow(
|
||||
}
|
||||
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
|
||||
// only the button's enabled/disabled flip recomposes the row header.
|
||||
val canScrollBack by remember(rowState) {
|
||||
derivedStateOf { rowState.firstVisibleItemIndex > 0 }
|
||||
derivedStateOf { rowState.canScrollBackward }
|
||||
}
|
||||
val canScrollForward by remember(rowState) {
|
||||
derivedStateOf { rowState.canScrollForward }
|
||||
@@ -232,22 +253,34 @@ internal fun MediaRow(
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (row.items.isNotEmpty()) {
|
||||
if (viewAllLabel != null && onViewAll != null) {
|
||||
ViewAllButton(
|
||||
label = viewAllLabel,
|
||||
focusRequester = viewAllFocusRequester,
|
||||
onClick = onViewAll,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
GalleryJumpButton(
|
||||
forward = false,
|
||||
enabled = canScrollBack,
|
||||
focusRequester = previousPageFocusRequester,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
|
||||
scope.launch { rowState.scrollToItem(target) }
|
||||
pagedCardIndex = target
|
||||
pageFocusRequestId += 1
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GalleryJumpButton(
|
||||
forward = true,
|
||||
enabled = canScrollForward,
|
||||
focusRequester = nextPageFocusRequester,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex + pageSize)
|
||||
.coerceAtMost(row.items.lastIndex)
|
||||
scope.launch { rowState.scrollToItem(target) }
|
||||
pagedCardIndex = target
|
||||
pageFocusRequestId += 1
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -321,6 +354,9 @@ internal fun MediaRow(
|
||||
if (index == requestedEntryIndex) {
|
||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
||||
}
|
||||
if (index == pagedCardIndex) {
|
||||
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
|
||||
}
|
||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
@@ -328,6 +364,29 @@ internal fun MediaRow(
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -374,6 +433,7 @@ internal fun MediaRow(
|
||||
private fun GalleryJumpButton(
|
||||
forward: Boolean,
|
||||
enabled: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
@@ -381,12 +441,11 @@ private fun GalleryJumpButton(
|
||||
onClick = { if (enabled) onClick() },
|
||||
onLongClick = null,
|
||||
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
|
||||
.width(42.dp)
|
||||
.height(36.dp)
|
||||
.focusProperties { canFocus = false },
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { canFocus = enabled },
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
@@ -394,10 +453,15 @@ private fun GalleryJumpButton(
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White.copy(alpha = 0.16f)
|
||||
focused -> MembyAccent.copy(alpha = 0.34f)
|
||||
enabled -> Color.White.copy(alpha = 0.08f)
|
||||
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,
|
||||
) {
|
||||
@@ -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 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 coil.compose.AsyncImage
|
||||
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.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
@@ -85,131 +88,40 @@ internal fun RequestCard(
|
||||
/** The trailing affordance, when pressing the card would do something. */
|
||||
action: RequestCardAction? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
FocusScaleContainer(
|
||||
MediaResultCard(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
detail = detail,
|
||||
artworkUrl = artworkUrl,
|
||||
detailColour = requestToneColour(status),
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(", "),
|
||||
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)) {
|
||||
// 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 ->
|
||||
modifier = modifier,
|
||||
artworkTopStartContent = if (status != RequestStatus.REQUESTABLE) {
|
||||
{ RequestStatusBadge(status, requestStatusChipLabel(status, statusLabel, progress)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
artworkBottomStartContent = {
|
||||
MediaResultTypeGlyph(isSeries = mediaType.equals("series", ignoreCase = true))
|
||||
},
|
||||
bodyContent = 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) {
|
||||
RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused)
|
||||
} else if (action != null) {
|
||||
RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
* 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.
|
||||
*
|
||||
@@ -364,35 +258,5 @@ private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestCardSkeleton(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)),
|
||||
)
|
||||
MediaResultCardSkeleton(modifier)
|
||||
}
|
||||
|
||||
@@ -37,11 +37,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
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.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.runtime.Composable
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -83,13 +79,13 @@ import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
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.MembyAccentInk
|
||||
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. */
|
||||
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.
|
||||
*
|
||||
@@ -168,7 +152,8 @@ fun SearchScreen(
|
||||
|
||||
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,
|
||||
// just as browse destinations open on their primary content action.
|
||||
val keyboardEntry = contentFocusRequester
|
||||
@@ -191,23 +176,21 @@ fun SearchScreen(
|
||||
}
|
||||
val hasResultsTarget = when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() || state.genreSuggestions.isNotEmpty()
|
||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() ||
|
||||
state.isDiscovery -> state.genreSuggestions.isNotEmpty()
|
||||
else -> state.results.any { it.isMovie || it.isSeries } || state.requestCandidates.isNotEmpty() ||
|
||||
(state.requestsAvailable && shouldSearch(state.query))
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
|
||||
// A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with
|
||||
// it — so the focus it was holding belongs to nothing unless something takes it. The
|
||||
// grid claims it as soon as there is a card to land on, which is where the viewer is
|
||||
// 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.
|
||||
// The genre strip remains above its shelf. Once a first result exists the list takes
|
||||
// focus, which is where the viewer is already looking; an empty genre hands the remote
|
||||
// back to the keyboard rather than leaving the television on a dead end.
|
||||
LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) {
|
||||
if (state.genre == null) return@LaunchedEffect
|
||||
when {
|
||||
state.results.isNotEmpty() || state.errorMessage != null ->
|
||||
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
|
||||
state.results.any { it.isMovie || it.isSeries } || state.errorMessage != null ->
|
||||
if (runCatching { searchResultsEntry.requestFocus() }.isSuccess) focusInResults = true
|
||||
!state.isLoading -> runCatching { keyboardReturn.requestFocus() }
|
||||
// Still loading: the branch above takes it the moment the first page lands.
|
||||
else -> Unit
|
||||
@@ -216,10 +199,9 @@ fun SearchScreen(
|
||||
|
||||
LaunchedEffect(state.genre, restoreGenreChipFocus) {
|
||||
if (state.genre == null && restoreGenreChipFocus) {
|
||||
// clearGenre remounts discovery and its genre chips. Wait for the first chip's
|
||||
// focus node before handing the remote back to where this level was opened.
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
|
||||
// The strip stays mounted while a genre is open, so Back can restore its stable
|
||||
// entry target as soon as the result list has left composition.
|
||||
if (runCatching { genresEntry.requestFocus() }.isSuccess) focusInResults = true
|
||||
restoreGenreChipFocus = false
|
||||
}
|
||||
}
|
||||
@@ -267,7 +249,7 @@ fun SearchScreen(
|
||||
SearchPane(
|
||||
state = state,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
resultsEntry = if (state.genreSuggestions.isNotEmpty()) genresEntry else searchResultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
@@ -285,13 +267,48 @@ fun SearchScreen(
|
||||
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
ResultsPane(
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(start = 28.dp, end = 32.dp, top = 24.dp, bottom = 12.dp),
|
||||
) {
|
||||
SearchGenres(
|
||||
genres = state.genreSuggestions,
|
||||
entryFocusRequester = genresEntry,
|
||||
keyboardReturnFocusRequester = keyboardReturn,
|
||||
resultsFocusRequester = searchResultsEntry,
|
||||
resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } ||
|
||||
(state.errorMessage != null && state.results.isEmpty()) ||
|
||||
(state.requestsAvailable && shouldSearch(state.query)),
|
||||
onFocused = {
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
},
|
||||
onSelected = viewModel::onGenreSelected,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
if (state.requestMode) {
|
||||
RequestOptions(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
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,
|
||||
discoveryItems = discoveryItems,
|
||||
artworkUrlFor = { item ->
|
||||
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
|
||||
},
|
||||
onItemFocused = { item ->
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
@@ -299,14 +316,13 @@ fun SearchScreen(
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onRequest = viewModel::request,
|
||||
onShowRequests = viewModel::showRequests,
|
||||
onGenreSelected = viewModel::onGenreSelected,
|
||||
onBackFromGenre = closeGenre,
|
||||
onLoadMore = viewModel::loadMore,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -558,7 +574,7 @@ internal fun TvKeyboard(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// 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.
|
||||
.focusProperties {
|
||||
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("Couldn’t load more titles.", color = Muted, fontSize = 13.sp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestOptions(
|
||||
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 ACTION_ROW_INDEX = 36
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@ data class SearchUiState(
|
||||
* clearing something the viewer never typed.
|
||||
*/
|
||||
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,
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -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
|
||||
* of it to fetch and nothing already in flight — the grid asks on every scroll, and it
|
||||
* The result list is near the end of what it holds. Ignored unless there is a genre open,
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* 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) {
|
||||
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
|
||||
// card: the shelf is ordered by premiere date and sort name precisely
|
||||
// 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
|
||||
// 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
|
||||
@@ -435,7 +435,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
// 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
|
||||
// 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.
|
||||
val items = found.distinctItems()
|
||||
// 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("Couldn’t 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 @@
|
||||
0.1.63
|
||||
0.1.64
|
||||
|
||||
Reference in New Issue
Block a user