0.2.98
This commit is contained in:
@@ -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 ->
|
||||
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,
|
||||
)
|
||||
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))
|
||||
}
|
||||
},
|
||||
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,27 +267,61 @@ fun SearchScreen(
|
||||
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
ResultsPane(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
discoveryItems = discoveryItems,
|
||||
onItemFocused = { item ->
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
onItemFocused(item)
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onRequest = viewModel::request,
|
||||
onShowRequests = viewModel::showRequests,
|
||||
onGenreSelected = viewModel::onGenreSelected,
|
||||
onBackFromGenre = closeGenre,
|
||||
onLoadMore = viewModel::loadMore,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
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 = searchResultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onRequest = viewModel::request,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
SearchResults(
|
||||
state = state,
|
||||
entryFocusRequester = searchResultsEntry,
|
||||
genresFocusRequester = genresEntry,
|
||||
genresPresent = state.genreSuggestions.isNotEmpty(),
|
||||
keyboardReturnFocusRequester = keyboardReturn,
|
||||
returnFocusItemId = returnFocusItemId,
|
||||
returnFocusRequester = returnFocusRequester,
|
||||
artworkUrlFor = { item ->
|
||||
repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500)
|
||||
},
|
||||
onItemFocused = { item ->
|
||||
focusInResults = true
|
||||
onContentFocused()
|
||||
onItemFocused(item)
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onShowRequests = viewModel::showRequests,
|
||||
onLoadMore = viewModel::loadMore,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,7 +574,7 @@ internal fun TvKeyboard(
|
||||
modifier = Modifier
|
||||
.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 = "",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user