Release Memby 0.2.64

This commit is contained in:
ponzischeme89
2026-08-14 13:32:14 +12:00
parent ad0c74f880
commit 05d95f51cd
33 changed files with 1142 additions and 112 deletions
@@ -515,6 +515,12 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/** The gateway resolves placement, policy and scheduling; the television only draws it. */
suspend fun getActiveHero(placement: String): List<HomeRow> {
if (!ServerConfig.isGateway) return emptyList()
return requireGateway().activeHero(placement).rows
}
/** A shuffled set of movies & shows that actually have a backdrop image. */
suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
if (ServerConfig.isGateway) {
@@ -905,6 +911,15 @@ class EmbyRepository(private val settings: SettingsStore) {
).title
}
/** Records a problem against one concrete library movie or episode. */
suspend fun reportMediaProblem(itemId: String, reason: String, comment: String, requestReplacement: Boolean) {
check(ServerConfig.isGateway) { "Media reporting requires the Memby gateway" }
requireGateway().reportMediaProblem(
itemId,
com.ponzischeme89.memby.data.model.GatewayMediaReport(reason, comment.trim(), requestReplacement),
)
}
/** This viewer's own asks, newest first, with the state each has reached. */
suspend fun getMyRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList {
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
@@ -392,6 +392,13 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
@Serializable
data class GatewayActiveHero(
val placement: String = "",
val source: String = "automatic",
val rows: List<HomeRow> = emptyList(),
)
/** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable
data class MediaRating(
@@ -550,6 +557,13 @@ data class GatewayMediaRequestResult(
val title: String = "",
)
@Serializable
data class GatewayMediaReport(
val reason: String,
val comment: String = "",
val requestReplacement: Boolean = false,
)
@Serializable
data class GatewayPrerollSchedule(
val today: List<GatewayPrerollEntry> = emptyList(),
@@ -6,11 +6,13 @@ import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayActiveHero
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
import com.ponzischeme89.memby.data.model.GatewayMediaReport
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayPlayback
@@ -68,6 +70,9 @@ interface GatewayApi {
@GET("v1/home")
suspend fun home(@Query("limit") limit: Int): GatewayHome
@GET("v1/heroes/active")
suspend fun activeHero(@Query("placement") placement: String): GatewayActiveHero
@GET("v1/screensaver")
suspend fun screensaver(@Query("limit") limit: Int): GatewayItems
@@ -115,6 +120,12 @@ interface GatewayApi {
@POST("v1/requests")
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
@POST("v1/items/{id}/report")
suspend fun reportMediaProblem(
@Path("id") itemId: String,
@Body body: GatewayMediaReport,
)
@GET("v1/requests")
suspend fun myRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList
@@ -289,6 +289,7 @@ internal fun DetailPageScaffold(
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.(DetailTab) -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -448,6 +449,15 @@ internal fun DetailPageScaffold(
Box(Modifier.fillMaxSize()) { content(visibleTab) }
}
}
footer?.let { footerContent ->
item(key = "footer") {
Box(
Modifier.fillMaxWidth().padding(
start = DetailSideGutter, end = DetailSideGutter, bottom = 64.dp,
),
) { footerContent() }
}
}
}
confirmation?.let {
@@ -310,6 +310,7 @@ internal fun EpisodeDetailContent(
)
}
},
footer = { MediaReportControl(item) },
) {
EpisodeSeasonPane(
episodes = episodes,
@@ -1963,6 +1963,9 @@ private fun HomeScreen(
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) {
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
}
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
var myShowsLoading by remember(settings.userId) { mutableStateOf(true) }
var myShowsError by remember(settings.userId) { mutableStateOf<String?>(null) }
@@ -2316,16 +2319,42 @@ private fun HomeScreen(
// The server rows are passed in *unfiltered*, beside the browse rows the launcher
// draws: serverHomeRows drops the hero row, because it is consumed here rather than
// rendered as a shelf, so this is the only thing that can still see it.
val homeHeroMovies = remember(rows, homeContent.rows, selectedDestination, heroDay) {
if (selectedDestination == BrowseDestination.HOME) {
selectHomeHeroMovies(rows, heroDay, homeContent.rows)
} else {
emptyList()
// Warm both section heroes while Home is being read. Navigation then normally draws
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
LaunchedEffect(settings.userId) {
listOf(
BrowseDestination.MOVIES to "movies",
BrowseDestination.SHOWS to "tv_shows",
).forEach { (destination, placement) ->
launch {
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
sectionHeroRows = sectionHeroRows + (destination to resolved)
}
}
}
}
LaunchedEffect(selectedDestination, settings.userId) {
val placement = when (selectedDestination) {
BrowseDestination.MOVIES -> "movies"
BrowseDestination.SHOWS -> "tv_shows"
else -> null
} ?: return@LaunchedEffect
if (sectionHeroRows.containsKey(selectedDestination)) return@LaunchedEffect
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
sectionHeroRows = sectionHeroRows + (selectedDestination to resolved)
}
}
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
when (selectedDestination) {
BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows)
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
else -> emptyList()
}
}
val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
shouldShowHomeGreeting(
hasHero = homeHeroMovies.isNotEmpty(),
hasHero = contextualHeroItems.isNotEmpty(),
focusedRowId = focusedHomeRowId,
rowIds = rows.map(HomeBrowseRow::id),
)
@@ -2377,10 +2406,10 @@ private fun HomeScreen(
}
LaunchedEffect(
selectedDestination,
homeHeroMovies.firstOrNull()?.item?.id,
contextualHeroItems.firstOrNull()?.item?.id,
rows.firstOrNull()?.items?.firstOrNull()?.id,
) {
val firstItem = homeHeroMovies.firstOrNull()?.item
val firstItem = contextualHeroItems.firstOrNull()?.item
?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
firstItem?.let(homeViewModel::focusItem)
if (firstItem != null && !initialFocusRequested) {
@@ -2655,13 +2684,13 @@ private fun HomeScreen(
verticalState.firstVisibleItemScrollOffset == 0
}
}
val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
val hasContextualHero = contextualHeroItems.isNotEmpty()
// Set by a card in a shelf taking focus and cleared by the hero taking it
// back. Keyed on the destination so arriving at Home never inherits where
// focus happened to be on another one.
var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) }
val showHomeHero = shouldShowHomeMovieHero(
hasMovies = hasHomeHero,
hasMovies = hasContextualHero,
listAtTop = homeListAtTop,
rowFocused = rowFocusedBelowHero,
)
@@ -2747,7 +2776,7 @@ private fun HomeScreen(
Column(Modifier.fillMaxSize()) {
if (showHomeHero) {
HomeMovieHero(
movies = homeHeroMovies,
movies = contextualHeroItems,
navigationFocusRequester = navigationFocusRequester,
contentEntryFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
@@ -2771,7 +2800,7 @@ private fun HomeScreen(
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
category = "content", action = "open", screen = "home",
category = "content", action = "open", screen = selectedDestination.name.lowercase(),
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
itemName = item.name, itemType = item.type,
)
@@ -2935,7 +2964,7 @@ private fun HomeScreen(
availableWidth = contentWidth,
navigationFocusRequester = navigationFocusRequester,
contentEntryFocusRequester = contentFocusRequester.takeIf {
!hasHomeHero &&
!hasContextualHero &&
(selectedDestination != BrowseDestination.SHOWS ||
(!genreBrowserEnabled && myShows.isEmpty())) &&
(selectedDestination != BrowseDestination.MOVIES ||
@@ -2943,7 +2972,7 @@ private fun HomeScreen(
row.id == firstPopulatedRowId
},
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
hasHomeHero && row.id == firstPopulatedRowId
hasContextualHero && row.id == firstPopulatedRowId
},
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
returnFocusRequester = cardReturnFocusRequester,
@@ -2967,7 +2996,7 @@ private fun HomeScreen(
// not composed while a row holds focus, so there is
// nothing above for Compose's own focus search to
// find and the press would otherwise be dead.
if (direction == RowFocusDirection.UP && hasHomeHero) {
if (direction == RowFocusDirection.UP && hasContextualHero) {
rowFocusedBelowHero = false
scope.launch {
verticalState.animateScrollToItem(0)
@@ -3719,7 +3748,7 @@ private fun HomeScreen(
scope.launch {
kotlinx.coroutines.delay(16L)
val removalFocusRequester = if (
selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
contextualHeroItems.isNotEmpty()
) {
heroRowEntryFocusRequester
} else {
@@ -227,6 +227,7 @@ internal fun MediaDetailContent(
)
}
},
footer = { MediaReportControl(item) },
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
@@ -0,0 +1,91 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.model.BaseItem
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
private val reportReasons = listOf(
"playback_problem" to "Playback problem", "poor_video_quality" to "Poor video quality",
"poor_audio_quality" to "Poor audio quality", "audio_out_of_sync" to "Audio out of sync",
"wrong_media" to "Wrong episode or movie", "missing_or_corrupt" to "Missing or corrupt content",
"subtitles_problem" to "Subtitles problem", "file_stops" to "File stops during playback",
"request_better_copy" to "Request a better or new copy", "other" to "Other",
)
/** The deliberately quiet last action on a detail page. It is absent on direct Emby mode. */
@Composable
internal fun MediaReportControl(item: BaseItem) {
if (!ServerConfig.isGateway || (item.type != "Movie" && item.type != "Episode")) return
var open by remember(item.id) { mutableStateOf(false) }
if (open) MediaReportDialog(item, onClose = { open = false })
Text(
"Report a problem",
color = DetailQuietText,
fontSize = 14.sp,
modifier = Modifier.fillMaxWidth().clickable { open = true }.padding(vertical = 22.dp),
)
}
@Composable
private fun MediaReportDialog(item: BaseItem, onClose: () -> Unit) {
var selected by remember { mutableStateOf("playback_problem") }
var replacement by remember { mutableStateOf(false) }
val mayReplace = ServiceLocator.repository.requestsPermitted
val scope = rememberCoroutineScope()
MediaReportDialogContent(item.name, mayReplace, selected, replacement, { selected = it }, { replacement = it }, onClose) {
scope.launch {
runCatching { ServiceLocator.repository.reportMediaProblem(item.id, selected, "", replacement) }
onClose()
}
}
}
@Composable
internal fun MediaReportDialogContent(
title: String, mayReplace: Boolean, selected: String, replacement: Boolean,
onSelect: (String) -> Unit, onReplacementChanged: (Boolean) -> Unit,
onClose: () -> Unit, onSend: () -> Unit,
) {
Column(Modifier.fillMaxWidth().background(Color(0xFF202126), RoundedCornerShape(16.dp)).padding(28.dp)) {
Text("Report a problem", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(title, color = DetailQuietText, fontSize = 14.sp, modifier = Modifier.padding(bottom = 12.dp))
reportReasons.forEach { (value, label) ->
Row(Modifier.fillMaxWidth().clickable { onSelect(value) }.padding(vertical = 9.dp)) {
Text(if (selected == value) "" else "", color = DetailAccent, fontSize = 17.sp)
Spacer(Modifier.width(12.dp)); Text(label, color = Color.White, fontSize = 16.sp)
}
}
if (mayReplace) Row(Modifier.fillMaxWidth().clickable { onReplacementChanged(!replacement) }.padding(top = 12.dp, bottom = 18.dp)) {
Text(if (replacement) "" else "", color = DetailAccent, fontSize = 18.sp); Spacer(Modifier.width(12.dp))
Text("Request a replacement using the configured quality profile", color = Color.White, fontSize = 15.sp)
}
Row {
Text("Cancel", color = DetailQuietText, modifier = Modifier.clickable(onClick = onClose).padding(14.dp))
Spacer(Modifier.width(18.dp))
Text("Send report", color = DetailAccent, fontWeight = FontWeight.Bold, modifier = Modifier.clickable(onClick = onSend).padding(14.dp),
)
}
}
}
@@ -0,0 +1,32 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Renders the permitted-user report flow to build/screenshots/media-report/. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class MediaReportScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun replacementRequestDialog() {
compose.setContent {
MediaReportDialogContent(
title = "Severance — S02E04",
mayReplace = true,
selected = "playback_problem",
replacement = true,
onSelect = {}, onReplacementChanged = {}, onClose = {}, onSend = {},
)
}
compose.onRoot().captureRoboImage("build/screenshots/media-report/media-report-replacement-dialog.png")
}
}