Release Memby 0.2.64
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user