Improve playback, preroll and TV experience

This commit is contained in:
ponzischeme89
2026-08-10 07:11:14 +12:00
parent 598a4f5c75
commit d2f2eb62be
30 changed files with 1380 additions and 142 deletions
+3
View File
@@ -1,6 +1,9 @@
## 0.2.39 — 2026-08-09
- Improved: HEVC playback performance
- Added: Cast panels now mark deceased performers and open biographies and filmographies.
- Added: An optional confirmation before the Back button closes Memby.
- Improved: Reviewed D-pad navigation across the Android TV experience and documented follow-up work.
- Fixed: Continue Watching now keeps its saved position and shows Resume instead of Play.
## 0.2.38 — 2026-08-09
- Improved: Ratings are larger and easier to read.
+12
View File
@@ -1180,6 +1180,18 @@ pays DNS, TCP, TLS and Emby's file open. Pre-warming that connection is the open
opportunity. Two things that look like causes and are not: the subtitle auto-selection
costs 20200 ms, not seconds, and the seek itself is about 900 ms.
**The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one
process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare
on the main queue's idle handler, so decoder construction and the local resource read never
sit in `Application.onCreate` or in front of launcher composition. When a fresh playback
requires the preroll, `PlayerActivity` borrows that player and shows it in the pre-roll video
frame while the requested title negotiates and prepares, paused at zero, behind the overlay.
Three details preserve the performance claim: the clip's actual duration owns the hand-off
rather than a stale configured estimate; the old paused-content-frame path remains the
failure fallback; and the preroll player is stopped and parked at hand-off so it releases its
hardware decoder while HEVC content plays. Returning to Home prepares the same instance again
in the next idle window rather than constructing one per title.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
@@ -6,6 +6,7 @@ import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.ponzischeme89.memby.data.remote.HttpStack
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
@@ -46,6 +47,9 @@ class MembyApp : Application() {
.build(),
)
ServiceLocator.init(this)
// Registers an idle callback only: the local four-second clip is prepared after
// the launcher's queued start-up work, never on its critical path.
PrerollPreloader.start(this)
}
/**
@@ -19,6 +19,7 @@ import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
@@ -983,6 +984,58 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
private val personCache = java.util.concurrent.ConcurrentHashMap<String, BaseItem>()
private val filmographyCache = java.util.concurrent.ConcurrentHashMap<String, List<BaseItem>>()
/** Biography and life dates for a cast member. Emby models a person as an item. */
suspend fun getPersonDetails(personId: String): BaseItem {
require(personId.isNotBlank()) { "Person id is required" }
val cacheKey = "${snapshot.userId.orEmpty()}:$personId"
personCache[cacheKey]?.let { return it }
val person = if (ServerConfig.isGateway) {
requireGateway().person(personId)
} else {
val userId = snapshot.userId ?: error("Not connected")
requireApi().getItem(
userId = userId,
itemId = personId,
fields = "Overview,Genres,PrimaryImageAspectRatio",
)
}
personCache[cacheKey] = person
return person
}
/** Films and series associated with a person, newest first. */
suspend fun getPersonFilmography(personId: String): List<BaseItem> {
require(personId.isNotBlank()) { "Person id is required" }
val cacheKey = "${snapshot.userId.orEmpty()}:$personId"
filmographyCache[cacheKey]?.let { return it }
val items = if (ServerConfig.isGateway) {
requireGateway().personFilmography(personId).items
} else {
val userId = snapshot.userId ?: error("Not connected")
requireApi().getItems(
userId,
mapOf(
"PersonIds" to personId,
"IncludeItemTypes" to "Movie,Series",
"Recursive" to "true",
"SortBy" to "ProductionYear,SortName",
"SortOrder" to "Descending",
"Limit" to "60",
"Fields" to "Overview,ProductionYear,PrimaryImageAspectRatio",
"EnableImages" to "true",
"EnableImageTypes" to "Primary",
"ImageTypeLimit" to "1",
"EnableUserData" to "true",
),
).items
}
filmographyCache[cacheKey] = items
return items
}
private val ratingsCache = java.util.concurrent.ConcurrentHashMap<String, List<com.ponzischeme89.memby.data.model.MediaRating>>()
/** Optional ratings that never block essential metadata. The gateway owns a durable
@@ -1402,22 +1455,27 @@ class EmbyRepository(private val settings: SettingsStore) {
)
/**
* Returns a prefetched stream if one is sitting ready for [itemId], without suspending
* Returns a prefetched stream if one is sitting ready for [request], without suspending
* and without starting a request. This is what lets the launcher tell instantly whether
* pressing Play can hand the player a URL or must let it resolve one for itself, so
* that decision never costs a frame of its own.
*/
fun readyPlayableForLaunch(itemId: String): Playable? {
fun readyPlayableForLaunch(request: PlaybackRequest): Playable? {
// tryLock rather than a blocking wait, and failing to take it is a legitimate
// answer: the lock is held exactly when a resolution is in flight, and the caller's
// fallback — letting the player await it — is what should happen then anyway.
if (!playableMutex.tryLock()) return null
try {
val entry = playableCache[itemId] ?: return null
val entry = playableCache[request.itemId] ?: return null
if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
// Consumed: negotiated session state must never be handed out twice.
playableCache.remove(itemId)
return entry.playable
playableCache.remove(request.itemId)
return entry.playable.copy(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = entry.playable.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
),
)
} finally {
playableMutex.unlock()
}
@@ -1444,9 +1502,22 @@ class EmbyRepository(private val settings: SettingsStore) {
?.playable
ready to if (ready == null) playableInFlight[request.itemId] else null
}
if (cached != null) return cached
if (cached != null) {
return cached.copy(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = cached.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
),
)
}
if (inFlight != null) {
return inFlight.await().also {
val resolved = inFlight.await()
return resolved.copy(
resumePositionMs = launchResumePositionMs(
resolvedPositionMs = resolved.resumePositionMs,
requestedPositionMs = request.resumePositionMs,
),
).also {
playableMutex.withLock { playableCache.remove(request.itemId) }
}
}
@@ -2138,13 +2209,17 @@ class EmbyRepository(private val settings: SettingsStore) {
startTimeTicks = millisecondsToTicks(positionMs),
enableDirectPlay = !forceTranscode,
enableDirectStream = !forceTranscode,
// A compatibility retry must re-encode the failing video codec.
// Leaving stream-copy enabled can return the same HEVC Main 10
// elementary stream inside HLS and reproduce the decoder failure.
allowVideoStreamCopy = !forceTranscode,
subtitleStreamIndex = subtitleStreamIndex,
currentPlaySessionId = currentPlaySessionId,
deviceProfile = if (forceTranscode) {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
)
.copy(directPlayProfiles = emptyList())
.h264TranscodeFallback()
} else {
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
capabilities = devicePlaybackCapabilities,
@@ -2558,6 +2633,10 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
/** A positive position on the pressed card outranks an older prefetched zero. */
internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long =
if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L)
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
@@ -212,6 +212,17 @@ data class DeviceProfile(
}
}
/**
* A decoder recovery must change the video format, not merely put the same elementary
* stream into HLS. Emby otherwise sees HEVC in the ordinary transcoding profile and may
* stream-copy the codec that has just failed on this television.
*/
internal fun DeviceProfile.h264TranscodeFallback(): DeviceProfile = copy(
directPlayProfiles = emptyList(),
transcodingProfiles = transcodingProfiles.map { it.copy(videoCodec = "h264") },
codecProfiles = codecProfiles.filter { it.codec.equals("h264", ignoreCase = true) },
)
@Serializable
data class CodecProfile(
@SerialName("Type") val type: String = "Video",
@@ -332,6 +343,9 @@ data class BaseItem(
// is the one date that distinguishes it from its neighbours in a list, so it is asked
// for by name in every episode query — it is not a default field.
@SerialName("PremiereDate") val premiereDate: String? = null,
// For a Person, Emby uses PremiereDate for their birth and EndDate for their death.
// They remain wire-named media fields because series use the same properties.
@SerialName("EndDate") val endDate: String? = null,
@SerialName("OfficialRating") val officialRating: String? = null,
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@@ -184,6 +184,12 @@ interface GatewayApi {
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@GET("v1/people/{id}")
suspend fun person(@Path("id") personId: String): BaseItem
@GET("v1/people/{id}/filmography")
suspend fun personFilmography(@Path("id") personId: String): GatewayItems
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@@ -307,12 +307,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
fun focusItem(item: BaseItem) {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
val focused = (cached ?: item).copy(
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
membyRecommendationReason = item.membyRecommendationReason
?: cached?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
)
val focused = focusedItemWithMetadata(item, cached)
_focusedItem.value = focused
metadataJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
@@ -322,7 +317,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// Play click a memory lookup. The repository single-flights requests,
// so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
// The row owns live UserData. Prefetching the metadata-cache copy used
// to turn a 5% resume point into zero after details had been visited.
launch { runCatching { repository.prefetchPlayable(focused) } }
}
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
@@ -334,11 +331,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
val taggedDetails = details.copy(
membyAiringToday = focused.membyAiringToday,
membyRecommendationReason = focused.membyRecommendationReason,
membyCompatibility = focused.membyCompatibility,
)
val taggedDetails = focusedItemWithMetadata(item, details)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
@@ -580,6 +573,22 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* Adds rich detail metadata without replacing the row's live per-user state.
*
* Continue Watching is the important case: its card knows the current playhead, while a
* cached/full metadata record may carry no UserData at all. Losing that state changes the
* button from Resume to Play and sends a zero resume position into playback.
*/
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
(metadata ?: item).copy(
userData = item.userData ?: metadata?.userData,
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
membyRecommendationReason = item.membyRecommendationReason
?: metadata?.membyRecommendationReason,
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
)
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
@@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
@@ -136,6 +137,7 @@ import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.search.SearchScreen
import com.ponzischeme89.memby.ui.settings.SettingsSheet
@@ -211,6 +213,14 @@ class MainActivity : ComponentActivity() {
}
PerformanceMonitor.start(this)
}
override fun onStart() {
super.onStart()
// Also runs when playback returns to Home. A used preroll player is parked while
// the programme runs so it does not retain a second decoder, then prepared again
// during the launcher's next idle window.
PrerollPreloader.start(this)
}
}
/**
@@ -395,6 +405,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
recommendationOnboarding?.prompted == true -> {
RecommendationOnboardingScreen(
onboarding = recommendationOnboarding!!,
onSkip = {
// Skip is for this visit only. Do not mark or save completion: the
// gateway may offer onboarding again on a later launch.
recommendationOnboarding = recommendationOnboarding!!.copy(completed = true)
},
onComplete = { ratings, actors, actresses, directors ->
repo.saveRecommendationRatings(ratings, actors, actresses, directors)
// Record it locally as well, so this profile's next cold start
@@ -459,6 +474,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
@@ -502,13 +518,23 @@ private fun ExitMembyConfirmation(
onClick = onStay,
modifier = Modifier
.focusRequester(stayFocus)
.focusProperties { right = exitFocus },
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Stay in Memby") }
Button(
onClick = onExit,
modifier = Modifier
.focusRequester(exitFocus)
.focusProperties { left = stayFocus },
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Close Memby") }
}
}
@@ -882,6 +908,7 @@ private fun ProfileEntryScreen(
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ProfileChooser(
profiles: List<EmbyProfile>,
currentProfileId: String?,
@@ -892,13 +919,18 @@ private fun ProfileChooser(
onAddProfile: () -> Unit,
onClose: (() -> Unit)?,
) {
val firstFocus = remember { FocusRequester() }
val addFocus = remember { FocusRequester() }
val backFocus = remember { FocusRequester() }
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
val orderedProfiles = remember(profiles, currentProfileId) {
profiles.sortedByDescending { it.id == currentProfileId }
}
LaunchedEffect(orderedProfiles.firstOrNull()?.id) {
firstFocus.requestFocus()
val profileIds = orderedProfiles.map(EmbyProfile::id)
val tileFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
LaunchedEffect(profileIds) {
kotlinx.coroutines.delay(16L)
if (orderedProfiles.isEmpty()) addFocus.requestFocus() else tileFocus.first().requestFocus()
}
Box(
modifier = Modifier
@@ -929,47 +961,81 @@ private fun ProfileChooser(
fontSize = 17.sp,
modifier = Modifier.padding(top = 8.dp, bottom = 30.dp),
)
Row(
LazyRow(
modifier = Modifier.fillMaxWidth().focusGroup(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.Top,
) {
orderedProfiles.forEachIndexed { index, profile ->
Box(modifier = Modifier.width(154.dp)) {
itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile ->
Column(
modifier = Modifier.width(154.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ProfileTile(
name = profile.username,
current = profile.id == currentProfileId,
enabled = switchingProfileId == null && removingProfileId == null,
onClick = { onSelect(profile) },
modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier,
modifier = Modifier
.focusRequester(tileFocus[index])
.focusProperties {
left = if (index > 0) tileFocus[index - 1] else FocusRequester.Cancel
right = if (index < orderedProfiles.lastIndex) {
tileFocus[index + 1]
} else {
addFocus
}
down = removeFocus[index]
},
)
ProfileDeleteButton(
profileName = profile.username,
enabled = switchingProfileId == null && removingProfileId == null,
onClick = { pendingRemoval = profile },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 7.dp, end = 22.dp),
.focusRequester(removeFocus[index])
.focusProperties {
up = tileFocus[index]
left = if (index > 0) removeFocus[index - 1] else FocusRequester.Cancel
right = if (index < orderedProfiles.lastIndex) {
removeFocus[index + 1]
} else {
FocusRequester.Cancel
}
down = if (onClose != null) backFocus else FocusRequester.Cancel
},
)
}
}
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = if (orderedProfiles.isEmpty()) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
item(key = "add-profile") {
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = Modifier
.focusRequester(addFocus)
.focusProperties {
left = tileFocus.lastOrNull() ?: FocusRequester.Cancel
right = FocusRequester.Cancel
down = if (onClose != null) backFocus else FocusRequester.Cancel
},
)
}
}
if (onClose != null) {
Spacer(Modifier.height(30.dp))
Button(
onClick = onClose,
enabled = switchingProfileId == null && removingProfileId == null,
modifier = Modifier
.focusRequester(backFocus)
.focusProperties {
up = removeFocus.firstOrNull() ?: addFocus
},
) { Text("Back to Memby") }
}
}
@@ -987,9 +1053,11 @@ private fun ProfileChooser(
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun RecommendationOnboardingScreen(
onboarding: RecommendationOnboarding,
onComplete: suspend (Map<String, Int>, List<String>, List<String>, List<String>) -> Unit,
onSkip: () -> Unit = {},
initialStageIndex: Int = 0,
previewArtwork: ImageBitmap? = null,
) {
@@ -1015,7 +1083,25 @@ private fun RecommendationOnboardingScreen(
var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var confirmingSkip by rememberSaveable { mutableStateOf(false) }
val stage = stages.getOrNull(stageIndex)
val choiceFocus = remember(stageIndex) { FocusRequester() }
val backFocus = remember(stageIndex) { FocusRequester() }
val primaryFocus = remember(stageIndex) { FocusRequester() }
LaunchedEffect(stageIndex, stage) {
kotlinx.coroutines.delay(16L)
runCatching {
if (stage == null) primaryFocus.requestFocus() else choiceFocus.requestFocus()
}
}
BackHandler {
when {
confirmingSkip -> confirmingSkip = false
stageIndex > 0 -> stageIndex--
else -> confirmingSkip = true
}
}
val finish: () -> Unit = {
if (!saving) {
@@ -1095,11 +1181,21 @@ private fun RecommendationOnboardingScreen(
Spacer(Modifier.weight(1f))
} else {
when (stage) {
"Movies" -> OnboardingTitleRow(movies, ratings, previewArtwork)
"TV shows" -> OnboardingTitleRow(shows, ratings, previewArtwork)
"Actors" -> OnboardingPeopleRow(onboarding.actors, selectedActors, previewArtwork)
"Actresses" -> OnboardingPeopleRow(onboarding.actresses, selectedActresses, previewArtwork)
"Directors" -> OnboardingPeopleRow(onboarding.directors, selectedDirectors, previewArtwork)
"Movies" -> OnboardingTitleRow(
movies, ratings, choiceFocus, primaryFocus, previewArtwork,
)
"TV shows" -> OnboardingTitleRow(
shows, ratings, choiceFocus, primaryFocus, previewArtwork,
)
"Actors" -> OnboardingPeopleRow(
onboarding.actors, selectedActors, choiceFocus, primaryFocus, previewArtwork,
)
"Actresses" -> OnboardingPeopleRow(
onboarding.actresses, selectedActresses, choiceFocus, primaryFocus, previewArtwork,
)
"Directors" -> OnboardingPeopleRow(
onboarding.directors, selectedDirectors, choiceFocus, primaryFocus, previewArtwork,
)
}
}
Spacer(Modifier.weight(1f))
@@ -1109,15 +1205,114 @@ private fun RecommendationOnboardingScreen(
color = Color(0xFF9DA8B0), fontSize = 14.sp, modifier = Modifier.weight(1f),
)
error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) }
if (stageIndex > 0) Button(onClick = { stageIndex-- }, enabled = !saving) { Text("Back") }
if (stageIndex > 0) {
Button(
onClick = { stageIndex-- },
enabled = !saving,
modifier = Modifier
.focusRequester(backFocus)
.focusProperties {
up = if (stage == null) FocusRequester.Cancel else choiceFocus
right = primaryFocus
},
) { Text("Back") }
}
Spacer(Modifier.width(10.dp))
if (stageIndex < stages.lastIndex) {
Button(onClick = { stageIndex++ }, enabled = !saving) { Text("Next") }
Button(
onClick = { stageIndex++ },
enabled = !saving,
modifier = Modifier
.focusRequester(primaryFocus)
.focusProperties {
up = if (stage == null) FocusRequester.Cancel else choiceFocus
left = if (stageIndex > 0) backFocus else FocusRequester.Cancel
},
) { Text("Next") }
} else {
Button(onClick = finish, enabled = !saving) { Text(if (saving) "Saving…" else "Start watching") }
Button(
onClick = finish,
enabled = !saving,
modifier = Modifier
.focusRequester(primaryFocus)
.focusProperties {
up = if (stage == null) FocusRequester.Cancel else choiceFocus
left = if (stageIndex > 0) backFocus else FocusRequester.Cancel
},
) { Text(if (saving) "Saving…" else "Start watching") }
}
}
}
if (confirmingSkip) {
OnboardingSkipConfirmation(
onKeepChoosing = { confirmingSkip = false },
onSkip = onSkip,
)
}
}
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun OnboardingSkipConfirmation(
onKeepChoosing: () -> Unit,
onSkip: () -> Unit,
) {
val keepFocus = remember { FocusRequester() }
val skipFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(16L)
runCatching { keepFocus.requestFocus() }
}
Box(
Modifier.fillMaxSize().zIndex(20f).background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Skip taste setup for now?",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"You can start watching now. Memby may offer these choices again later.",
color = Color(0xFFBCC4CA),
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = onKeepChoosing,
modifier = Modifier
.focusRequester(keepFocus)
.focusProperties {
left = FocusRequester.Cancel
right = skipFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Keep choosing") }
Button(
onClick = onSkip,
modifier = Modifier
.focusRequester(skipFocus)
.focusProperties {
left = keepFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Skip for now") }
}
}
}
}
@@ -1125,17 +1320,22 @@ private fun RecommendationOnboardingScreen(
private fun OnboardingTitleRow(
items: List<BaseItem>,
ratings: MutableMap<String, Int>,
entryFocus: FocusRequester,
footerFocus: FocusRequester,
previewArtwork: ImageBitmap? = null,
) {
val repo = ServiceLocator.repository
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
items(items, key = { it.id }) { item ->
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
val selected = ratings[item.id] == 5
FocusScaleContainer(
onFocused = {},
onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 },
contentDescription = "${item.name}${if (selected) ", selected" else ""}",
modifier = Modifier.width(148.dp),
modifier = Modifier
.width(148.dp)
.then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier)
.focusProperties { down = footerFocus },
) { focused ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
@@ -1164,17 +1364,22 @@ private fun OnboardingTitleRow(
private fun OnboardingPeopleRow(
people: List<RecommendationPerson>,
selectedPeople: MutableMap<String, Boolean>,
entryFocus: FocusRequester,
footerFocus: FocusRequester,
previewArtwork: ImageBitmap? = null,
) {
val repo = ServiceLocator.repository
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
items(people, key = { it.name }) { person ->
itemsIndexed(people, key = { _, person -> person.name }) { index, person ->
val selected = selectedPeople[person.name] == true
FocusScaleContainer(
onFocused = {},
onClick = { selectedPeople[person.name] = !selected },
contentDescription = "${person.name}${if (selected) ", selected" else ""}",
modifier = Modifier.width(148.dp),
modifier = Modifier
.width(148.dp)
.then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier)
.focusProperties { down = footerFocus },
) { focused ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
@@ -1253,11 +1458,12 @@ private fun ProfileDeleteButton(
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Box(
Row(
modifier = modifier
.size(28.dp)
.width(124.dp)
.height(34.dp)
.zIndex(2f)
.clip(CircleShape)
.clip(RoundedCornerShape(9.dp))
.background(
when {
!enabled -> Color(0xFF343A3F)
@@ -1268,29 +1474,32 @@ private fun ProfileDeleteButton(
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White else Color.White.copy(alpha = 0.55f),
shape = CircleShape,
shape = RoundedCornerShape(9.dp),
)
.onFocusChanged { focused = it.isFocused }
.clickable(enabled = enabled, onClick = onClick)
.semantics { contentDescription = "Remove $profileName" },
contentAlignment = Alignment.Center,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = "×",
text = "Remove user",
color = if (enabled) Color.White else Color(0xFF899198),
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ProfileRemovalConfirmation(
profile: EmbyProfile,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
val cancelFocus = remember { FocusRequester() }
val removeFocus = remember { FocusRequester() }
BackHandler(onBack = onCancel)
LaunchedEffect(profile.id) { cancelFocus.requestFocus() }
Box(
@@ -1321,9 +1530,26 @@ private fun ProfileRemovalConfirmation(
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = onCancel,
modifier = Modifier.focusRequester(cancelFocus),
modifier = Modifier
.focusRequester(cancelFocus)
.focusProperties {
left = FocusRequester.Cancel
right = removeFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Cancel") }
Button(onClick = onConfirm) { Text("Remove user") }
Button(
onClick = onConfirm,
modifier = Modifier
.focusRequester(removeFocus)
.focusProperties {
left = cancelFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Remove user") }
}
}
}
@@ -1543,8 +1769,8 @@ private fun HomeScreen(
// the activity, the layout and the decoder, none of which needed the answer. A cold
// start still resolves first — the pre-roll it opens with needs a stream to run
// behind it, and whether there is one to show is part of the same answer.
val ready = repo.readyPlayableForLaunch(item.id)
val request = repo.playbackRequest(item)
val ready = repo.readyPlayableForLaunch(request)
if (ready == null && request.resumePositionMs > 0L) {
playbackLauncher.launch(
PlayerActivity.intent(
@@ -17,7 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -92,17 +93,35 @@ fun MyAlertsPage(
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val listFocusRequester = remember { FocusRequester() }
val actionsFocusRequester = remember { FocusRequester() }
val notificationIds = notifications.map(UserNotification::id)
val rowFocusRequesters = remember(notificationIds) {
List(notificationIds.size) { FocusRequester() }
}
val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
val hasAlerts = notifications.isNotEmpty()
LaunchedEffect(hasAlerts) {
// One frame for the list to place its first row; an empty page has nothing below
// the actions to land on, so the chips take the remote instead.
delay(16)
runCatching {
if (hasAlerts) listFocusRequester.requestFocus() else actionsFocusRequester.requestFocus()
if (hasAlerts) rowFocusRequesters.first().requestFocus() else actionsFocusRequester.requestFocus()
}
}
LaunchedEffect(notificationIds) {
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
if (notifications.isEmpty()) {
pendingFocusIndex = null
return@LaunchedEffect
}
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
?: return@LaunchedEffect
listState.scrollToItem(targetIndex)
delay(16)
runCatching { rowFocusRequesters[targetIndex].requestFocus() }
pendingFocusIndex = null
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
Column(
@@ -149,19 +168,20 @@ fun MyAlertsPage(
AlertsEmptyState(enabled = preferences.enabled)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp),
) {
items(notifications, key = UserNotification::id) { notification ->
itemsIndexed(notifications, key = { _, notification -> notification.id }) {
index, notification ->
AlertRow(
notification = notification,
modifier = if (notification.id == notifications.first().id) {
Modifier.focusRequester(listFocusRequester)
} else {
Modifier
},
modifier = Modifier.focusRequester(rowFocusRequesters[index]),
onFocused = { if (notification.unread) onRead(notification) },
onClick = { onDismiss(notification) },
onClick = {
pendingFocusIndex = index
onDismiss(notification)
},
)
if (notification.id != notifications.last().id) {
Box(
@@ -179,6 +199,10 @@ fun MyAlertsPage(
}
}
/** The row now occupying the removed row's place, or the preceding row at the end. */
internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
@Composable
private fun AlertsHeader(total: Int, unread: Int) {
Column(
@@ -8,6 +8,7 @@ import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
@@ -19,6 +20,25 @@ data class CastMember(
val name: String,
val role: String? = null,
val imageUrl: String? = null,
val id: String = "",
val deathDate: String? = null,
)
data class FilmographyCredit(
val title: String,
val year: Int? = null,
val imageUrl: String? = null,
)
data class CastPersonPanel(
val id: String,
val name: String,
val role: String? = null,
val overview: String? = null,
val birthDate: String? = null,
val deathDate: String? = null,
val filmography: List<FilmographyCredit> = emptyList(),
val loaded: Boolean = false,
)
/**
@@ -32,6 +52,7 @@ data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(),
val loaded: Boolean = false,
val selectedPerson: CastPersonPanel? = null,
)
/**
@@ -47,14 +68,20 @@ fun bindCastPanel(
overlay: View,
state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> },
onMemberClick: (CastMember) -> Unit = {},
) {
val context = overlay.context
val selected = state.selectedPerson
overlay.findViewById<TextView>(R.id.player_cast_eyebrow).text = context.getString(
if (selected == null) R.string.player_cast_eyebrow else R.string.player_cast_profile_eyebrow,
)
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
text = state.title
isVisible = state.title.isNotBlank()
text = selected?.name ?: state.title
isVisible = !text.isNullOrBlank()
}
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
text = when {
selected != null && !selected.loaded -> context.getString(R.string.player_cast_profile_loading)
!state.loaded -> context.getString(R.string.player_cast_loading)
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
else -> ""
@@ -63,14 +90,26 @@ fun bindCastPanel(
}
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
people.removeAllViews()
state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) }
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty()
state.members.forEach { member ->
people.addView(castCard(context, member, loadImage, onMemberClick))
}
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible =
selected == null && state.members.isNotEmpty()
overlay.findViewById<LinearLayout>(R.id.player_cast_profile).apply {
isVisible = selected?.loaded == true
removeAllViews()
selected?.takeIf { it.loaded }?.let { bindPersonProfile(this, it, loadImage) }
}
overlay.findViewById<TextView>(R.id.player_cast_back_hint).setText(
if (selected == null) R.string.player_back_to_close else R.string.player_cast_back_to_cast,
)
}
private fun castCard(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
onClick: (CastMember) -> Unit,
): View {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
@@ -79,10 +118,7 @@ private fun castCard(
orientation = LinearLayout.VERTICAL
isFocusable = true
isClickable = true
// Nothing happens on a press. The panel is a reference, not a destination — there
// is no person page to open — but the card still has to be focusable, or a D-pad
// cannot scroll the row at all.
setOnClickListener { }
setOnClickListener { onClick(member) }
clipChildren = false
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(18)
@@ -96,17 +132,30 @@ private fun castCard(
}
addView(castPortrait(context, member, loadImage, ::dp))
addView(
TextView(context).apply {
addView(LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
setPadding(0, dp(9), 0, 0)
addView(TextView(context).apply {
text = member.name
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(9), 0, 0)
},
)
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
if (!member.deathDate.isNullOrBlank()) {
addView(ImageView(context).apply {
setImageResource(R.drawable.ic_deceased)
contentDescription = context.getString(R.string.player_cast_deceased)
setPadding(dp(4), 0, 0, 0)
}, LinearLayout.LayoutParams(dp(18), dp(16)))
}
})
member.role?.takeIf(String::isNotBlank)?.let { role ->
addView(
TextView(context).apply {
@@ -122,6 +171,144 @@ private fun castCard(
}
}
private fun bindPersonProfile(
container: LinearLayout,
person: CastPersonPanel,
loadImage: (ImageView, String) -> Unit,
) {
val context = container.context
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
val life = personLifeDates(person.birthDate, person.deathDate)
if (life.isNotBlank() || !person.role.isNullOrBlank()) {
container.addView(TextView(context).apply {
text = listOfNotNull(
person.role?.takeIf(String::isNotBlank),
life.takeIf(String::isNotBlank),
).joinToString(" · ")
setTextColor(Color.rgb(185, 193, 200))
textSize = 13f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
})
}
container.addView(TextView(context).apply {
text = person.overview?.takeIf(String::isNotBlank)
?: context.getString(R.string.player_cast_biography_empty)
setTextColor(Color.WHITE)
textSize = 14f
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(0f, 1.08f)
setPadding(0, dp(7), 0, 0)
})
container.addView(TextView(context).apply {
text = context.getString(R.string.player_cast_filmography)
setTextColor(Color.rgb(82, 181, 75))
textSize = 11f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
letterSpacing = 0.12f
setPadding(0, dp(13), 0, dp(5))
})
if (person.filmography.isEmpty()) {
container.addView(TextView(context).apply {
text = context.getString(R.string.player_cast_filmography_empty)
setTextColor(Color.rgb(185, 193, 200))
textSize = 13f
})
return
}
container.addView(HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
clipChildren = false
addView(LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
clipChildren = false
person.filmography.forEach { credit ->
addView(filmographyCard(context, credit, loadImage, ::dp))
}
})
})
}
private fun filmographyCard(
context: Context,
credit: FilmographyCredit,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
isFocusable = true
isClickable = true
setOnClickListener { }
background = context.getDrawable(R.drawable.player_cast_portrait_background)
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.03f else 1f)
.scaleY(if (focused) 1.03f else 1f)
.setDuration(120L)
.start()
}
setPadding(dp(5), dp(5), dp(9), dp(5))
layoutParams = LinearLayout.LayoutParams(dp(194), dp(68)).apply { marginEnd = dp(10) }
addView(FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(42), dp(58)).apply { marginEnd = dp(9) }
background = context.getDrawable(R.drawable.player_cast_portrait_background)
clipToOutline = true
addView(ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = credit.title
credit.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
})
})
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
addView(TextView(context).apply {
text = credit.title
setTextColor(Color.WHITE)
textSize = 12f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 2
ellipsize = TextUtils.TruncateAt.END
})
credit.year?.let { year ->
addView(TextView(context).apply {
text = year.toString()
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
})
}
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
}
internal fun personLifeDates(birthDate: String?, deathDate: String?): String {
val birth = formatPersonDate(birthDate)
val death = formatPersonDate(deathDate)
return when {
birth.isNotBlank() && death.isNotBlank() -> "$birth $death"
birth.isNotBlank() -> "Born $birth"
death.isNotBlank() -> "Died $death"
else -> ""
}
}
private fun formatPersonDate(value: String?): String {
val match = Regex("""^(\d{4})-(\d{2})-(\d{2})""").find(value.orEmpty()) ?: return ""
val (year, month, day) = match.destructured
val monthName = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
).getOrNull(month.toIntOrNull()?.minus(1) ?: -1) ?: return year
return "${day.toIntOrNull() ?: day} $monthName $year"
}
/**
* The portrait, with the person's initials underneath it.
*
@@ -87,3 +87,11 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? =
2 -> 3_000L
else -> null
}
/** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */
internal fun shouldRecoverProlongedRebuffer(
renderedFirstFrame: Boolean,
prerollActive: Boolean,
seekBuffering: Boolean,
recoveryAttempted: Boolean,
): Boolean = renderedFirstFrame && !prerollActive && !seekBuffering && !recoveryAttempted
@@ -76,6 +76,7 @@ import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
@@ -86,6 +87,9 @@ import com.ponzischeme89.memby.ui.ServiceAlertBanner
import com.ponzischeme89.memby.ui.randomWelcomeQuote
import com.ponzischeme89.memby.ui.theme.MembyTheme
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@@ -134,6 +138,8 @@ class PlayerActivity : ComponentActivity() {
private var streamStatusView: TextView? = null
private var retryJob: Job? = null
private var stablePlaybackJob: Job? = null
private var prolongedRebufferJob: Job? = null
private var prolongedRebufferRecoveryAttempted = false
private var automaticRetryAttempt = 0
private var renderedFirstFrame = false
private var requestStartedAtMs = 0L
@@ -160,6 +166,7 @@ class PlayerActivity : ComponentActivity() {
private var prerollEpisodeCode = ""
private var prerollRuntimeMs = 0L
private var prerollDurationMs = DEFAULT_PREROLL_DURATION_MS
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
private var pausePosterUrl: String? = null
private var pauseOverlay: View? = null
private var nowPlayingGroup: View? = null
@@ -222,7 +229,13 @@ class PlayerActivity : ComponentActivity() {
private var castJob: Job? = null
private var castPeople: List<EmbyPerson> = emptyList()
private var castLoaded = false
private var castProfiles: Map<String, BaseItem> = emptyMap()
private var selectedCastPerson: CastPersonPanel? = null
private var castPersonJob: Job? = null
private var prerollView: View? = null
private var localPrerollPlayer: ExoPlayer? = null
private var localPrerollView: PlayerView? = null
private var localPrerollListener: Player.Listener? = null
private var prerollTimerJob: Job? = null
private var prerollScheduleJob: Job? = null
private var prerollActive = true
@@ -391,10 +404,11 @@ class PlayerActivity : ComponentActivity() {
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
prerollDurationMs = intent.getLongExtra(
configuredPrerollDurationMs = intent.getLongExtra(
EXTRA_PREROLL_DURATION_MS,
DEFAULT_PREROLL_DURATION_MS,
).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS)
prerollDurationMs = configuredPrerollDurationMs
val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled)
val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
availableSubtitles = subtitles
@@ -489,8 +503,11 @@ class PlayerActivity : ComponentActivity() {
// with it, so the ceiling falls before the overlay goes up.
stepDownCreditsSpeed()
if (!prerollActive && !seekBuffering) showPlaybackLoading()
scheduleProlongedRebufferRecovery()
}
Player.STATE_READY -> {
prolongedRebufferJob?.cancel()
prolongedRebufferJob = null
trace.mark(PlaybackTrace.READY)
endSeekBuffering()
hidePlaybackError()
@@ -551,10 +568,10 @@ class PlayerActivity : ComponentActivity() {
renderedFirstFrame = true
endSeekBuffering()
hidePlaybackLoading()
if (!playbackStarted) {
if (!playbackStarted && !prerollActive) {
startPlaybackSession(playback)
}
if (prerollActive) {
if (prerollActive && localPrerollPlayer == null) {
startPrerollCountdown()
}
endFirstFrameTrace()
@@ -724,24 +741,127 @@ class PlayerActivity : ComponentActivity() {
useController = false
hideController()
}
enterPrerollVideoFrame()
if (!attachLocalPreroll()) enterPrerollVideoFrame()
bindPrerollNow()
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
prerollScheduleJob = lifecycleScope.launch {
val schedule = ServiceLocator.repository.prerollSchedule()
if (prerollActive) bindPrerollSchedule(schedule, loading = false)
}
bindInitialPrerollCountdown()
}
private fun attachLocalPreroll(): Boolean = runCatching {
val host = findViewById<FrameLayout>(R.id.player_preroll_video_host)
val playback = PrerollPreloader.acquire(this)
localPrerollPlayer = playback
val view = PlayerView(this).apply {
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = playback
}
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
updatePrerollDuration(playback.duration)
if (playback.playWhenReady) startPrerollCountdown()
}
Player.STATE_ENDED -> completePrerollCountdown()
else -> Unit
}
}
override fun onRenderedFirstFrame() {
updatePrerollDuration(playback.duration)
startPrerollCountdown()
}
override fun onPlayerError(error: PlaybackException) {
fallbackFromLocalPreroll(error)
}
}
localPrerollView = view
localPrerollListener = listener
playback.addListener(listener)
updatePrerollDuration(playback.duration)
host.addView(
view,
0,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
playback.seekTo(0L)
if (playback.playbackState == Player.STATE_IDLE) playback.prepare()
playback.playWhenReady = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
true
}.getOrElse { error ->
Log.w(PLAYBACK_LOG_TAG, "event=preroll_attach_failed", error)
disposeLocalPreroll(reuse = false)
false
}
private fun updatePrerollDuration(clipDurationMs: Long) {
val effective = effectivePrerollDurationMs(clipDurationMs, configuredPrerollDurationMs)
if (effective == prerollDurationMs) return
prerollDurationMs = effective
if (prerollTimerJob == null) bindInitialPrerollCountdown()
}
private fun bindInitialPrerollCountdown() {
val seconds = ceil(prerollDurationMs / 1_000.0).toInt().coerceAtLeast(1)
findViewById<PrerollCountdownView>(R.id.player_preroll_countdown).setCountdown(
seconds = ceil(prerollDurationMs / 1_000.0).toInt(),
seconds = seconds,
progress = 1f,
description = resources.getQuantityString(
R.plurals.player_preroll_countdown,
ceil(prerollDurationMs / 1_000.0).toInt(),
ceil(prerollDurationMs / 1_000.0).toInt(),
seconds,
seconds,
),
)
}
private fun completePrerollCountdown() {
if (!prerollActive || prerollMinimumElapsed) return
prerollTimerJob?.cancel()
prerollTimerJob = null
prerollMinimumElapsed = true
findViewById<PrerollCountdownView>(R.id.player_preroll_countdown).setCountdown(
seconds = 0,
progress = 0f,
description = getString(R.string.player_preroll_starting),
)
beginContentWhenReady()
}
private fun fallbackFromLocalPreroll(error: PlaybackException) {
if (!prerollActive) return
Log.w(
PLAYBACK_LOG_TAG,
"event=preroll_failed code=${error.errorCode} name=${error.errorCodeName}",
error,
)
disposeLocalPreroll(reuse = false)
prerollDurationMs = configuredPrerollDurationMs
if (prerollTimerJob == null) bindInitialPrerollCountdown()
enterPrerollVideoFrame()
player?.playWhenReady = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
if (player?.playbackState == Player.STATE_READY) startPrerollCountdown()
}
private fun disposeLocalPreroll(reuse: Boolean) {
val playback = localPrerollPlayer ?: return
localPrerollListener?.let(playback::removeListener)
localPrerollView?.player = null
(localPrerollView?.parent as? ViewGroup)?.removeView(localPrerollView)
localPrerollListener = null
localPrerollView = null
localPrerollPlayer = null
if (reuse) PrerollPreloader.recycle(playback) else PrerollPreloader.discard(playback)
}
/** Resume is a continuation, not a new programme start, so it bypasses the pre-roll. */
private fun startWithoutPreroll() {
prerollView = findViewById<View>(R.id.player_preroll).also {
@@ -781,7 +901,8 @@ class PlayerActivity : ComponentActivity() {
val nowMs = SystemClock.elapsedRealtime()
if (prerollCountdownAdvances(
lifecycleStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
playbackReady = player?.playbackState == Player.STATE_READY,
playbackReady = localPrerollPlayer?.isPlaying
?: (player?.playbackState == Player.STATE_READY),
)
) {
watchedMs = (watchedMs + nowMs - lastTickMs).coerceAtMost(durationMs)
@@ -789,13 +910,7 @@ class PlayerActivity : ComponentActivity() {
lastTickMs = nowMs
}
if (!prerollActive) return@launch
prerollMinimumElapsed = true
countdown.setCountdown(
seconds = 0,
progress = 0f,
description = getString(R.string.player_preroll_starting),
)
beginContentWhenReady()
completePrerollCountdown()
}
}
@@ -893,6 +1008,7 @@ class PlayerActivity : ComponentActivity() {
}
private fun finishPrerollHandOff(playback: Player) {
disposeLocalPreroll(reuse = true)
restoreFullscreenPlayer()
prerollView?.visibility = View.GONE
playerView?.apply {
@@ -1077,6 +1193,8 @@ class PlayerActivity : ComponentActivity() {
playerView?.useController = true
}
retryJob?.cancel()
prolongedRebufferJob?.cancel()
prolongedRebufferJob = null
stablePlaybackJob?.cancel()
// The skip is over however it ended; nothing after this may be withheld on its
// account, least of all the reconnecting notice or the error screen.
@@ -1121,6 +1239,8 @@ class PlayerActivity : ComponentActivity() {
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
retryJob?.cancel()
prolongedRebufferJob?.cancel()
prolongedRebufferJob = null
hidePlaybackError()
val playback = player ?: return
// A launch whose resolution failed has no media item to re-prepare and no playhead
@@ -1193,6 +1313,39 @@ class PlayerActivity : ComponentActivity() {
}
}
/**
* MediaCodec does not always throw when a marginal HEVC decoder wedges; some vendor
* implementations remain in BUFFERING indefinitely. After a frame has already played,
* one sustained, non-seek rebuffer is enough evidence to request the same compatibility
* fallback used for an explicit decoder error. The one-attempt bound prevents a slow
* server from repeatedly restarting a title that is already being transcoded.
*/
private fun scheduleProlongedRebufferRecovery() {
if (!shouldRecoverProlongedRebuffer(
renderedFirstFrame = renderedFirstFrame,
prerollActive = prerollActive,
seekBuffering = seekBuffering,
recoveryAttempted = prolongedRebufferRecoveryAttempted,
) || prolongedRebufferJob?.isActive == true
) return
val bufferingItem = itemId
prolongedRebufferJob = lifecycleScope.launch {
delay(PROLONGED_REBUFFER_RECOVERY_MS)
val playback = player ?: return@launch
if (itemId != bufferingItem || playback.playbackState != Player.STATE_BUFFERING) {
return@launch
}
prolongedRebufferRecoveryAttempted = true
Log.w(
PLAYBACK_LOG_TAG,
"event=prolonged_rebuffer item=${itemId.orEmpty()} " +
"positionMs=${playback.currentPosition} playMethod=$playMethod",
)
retryPlayback(refreshSource = true, forceTranscode = playMethod != "Transcode")
}
}
private fun showPlaybackError(failure: PlaybackFailure) {
hidePlaybackLoading()
playerView?.hideController()
@@ -2664,6 +2817,9 @@ class PlayerActivity : ComponentActivity() {
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
renderedFirstFrame = false
automaticRetryAttempt = 0
prolongedRebufferJob?.cancel()
prolongedRebufferJob = null
prolongedRebufferRecoveryAttempted = false
resetTimeRemainingCue()
// A skip aimed at the outgoing episode must not land in the incoming one.
resetSeekControls()
@@ -2746,6 +2902,8 @@ class PlayerActivity : ComponentActivity() {
// activity before our UP handler gets a chance to hide the active surface.
if (event.action == KeyEvent.ACTION_UP) {
when {
castOverlay?.isVisible == true && selectedCastPerson != null ->
showCastList()
castOverlay?.isVisible == true -> hideCastOverlay()
// The drop-up's download half is a level of its own, so Back leaves it
// before it leaves the menu — one press per level, never two at once.
@@ -2964,8 +3122,11 @@ class PlayerActivity : ComponentActivity() {
private fun loadCast() {
castJob?.cancel()
castPersonJob?.cancel()
castLoaded = false
castPeople = emptyList()
castProfiles = emptyMap()
selectedCastPerson = null
val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
castLoaded = true
bindCastOverlay()
@@ -2981,6 +3142,18 @@ class PlayerActivity : ComponentActivity() {
castLoaded = true
bindCastOverlay()
}
val profiles = coroutineScope {
loaded.filter { it.id.isNotBlank() }.map { person ->
async {
runCatching { ServiceLocator.repository.getPersonDetails(person.id) }
.getOrNull()
}
}.awaitAll().filterNotNull().associateBy(BaseItem::id)
}
if (itemId == requestedItemId) {
castProfiles = profiles
bindCastOverlay()
}
}
}
@@ -2992,10 +3165,63 @@ class PlayerActivity : ComponentActivity() {
}
private fun hideCastOverlay() {
castPersonJob?.cancel()
selectedCastPerson = null
castOverlay?.visibility = View.GONE
playerView?.showController()
}
private fun showCastList() {
castPersonJob?.cancel()
selectedCastPerson = null
bindCastOverlay()
}
private fun showCastPerson(member: CastMember) {
if (member.id.isBlank()) return
selectedCastPerson = CastPersonPanel(
id = member.id,
name = member.name,
role = member.role,
)
bindCastOverlay()
castPersonJob?.cancel()
castPersonJob = lifecycleScope.launch {
val personId = member.id
val (profile, filmography) = coroutineScope {
val profileRequest = async {
runCatching {
castProfiles[personId]
?: ServiceLocator.repository.getPersonDetails(personId)
}.getOrNull()
}
val filmographyRequest = async {
runCatching { ServiceLocator.repository.getPersonFilmography(personId) }
.getOrDefault(emptyList())
}
profileRequest.await() to filmographyRequest.await()
}
if (selectedCastPerson?.id != personId) return@launch
selectedCastPerson = CastPersonPanel(
id = personId,
name = profile?.name?.ifBlank { member.name } ?: member.name,
role = member.role,
overview = profile?.overview,
birthDate = profile?.premiereDate,
deathDate = profile?.endDate,
filmography = filmography.map { item ->
FilmographyCredit(
title = item.name,
year = item.productionYear,
imageUrl = ServiceLocator.repository.primaryUrl(item, 160),
)
},
loaded = true,
)
bindCastOverlay()
}
}
private fun bindCastOverlay() {
val overlay = castOverlay ?: return
bindCastPanel(
@@ -3003,23 +3229,36 @@ class PlayerActivity : ComponentActivity() {
state = CastPanelState(
title = playbackTitle,
members = castPeople.map { person ->
val profile = castProfiles[person.id]
CastMember(
name = person.name,
role = person.role,
imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
id = person.id,
deathDate = profile?.endDate,
)
},
loaded = castLoaded,
selectedPerson = selectedCastPerson,
),
// Coil is handed in rather than reached for inside the panel, which is what lets
// the screenshot test render the same cards without a network.
loadImage = { view, url -> view.load(url) },
onMemberClick = ::showCastPerson,
)
if (overlay.isVisible) {
overlay.findViewById<LinearLayout>(R.id.player_cast_people)
.getChildAt(0)
?.requestFocus()
?: overlay.requestFocus()
if (selectedCastPerson == null) {
overlay.findViewById<LinearLayout>(R.id.player_cast_people)
.getChildAt(0)
?.requestFocus()
?: overlay.requestFocus()
} else {
overlay.findViewById<LinearLayout>(R.id.player_cast_profile)
.getFocusables(View.FOCUS_FORWARD)
.firstOrNull()
?.requestFocus()
?: overlay.requestFocus()
}
}
}
@@ -3482,7 +3721,9 @@ class PlayerActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
if (prerollActive) {
player?.playWhenReady = true
// The local clip owns the opening frame. The main title is prepared behind
// the overlay but remains paused at zero until the hand-off.
localPrerollPlayer?.play() ?: run { player?.playWhenReady = true }
}
beginContentWhenReady()
if (stoppedInBackground && playbackStarted) {
@@ -3507,6 +3748,7 @@ class PlayerActivity : ComponentActivity() {
}
}
super.onStop()
localPrerollPlayer?.pause()
player?.pause()
}
@@ -3518,6 +3760,7 @@ class PlayerActivity : ComponentActivity() {
pendingResolveJob?.cancel()
prerollTimerJob?.cancel()
prerollScheduleJob?.cancel()
disposeLocalPreroll(reuse = true)
playbackStartCueJob?.cancel()
timeRemainingHideJob?.cancel()
seekCommitJob?.cancel()
@@ -3527,12 +3770,14 @@ class PlayerActivity : ComponentActivity() {
seasonFinaleHideJob?.cancel()
prerollView?.findViewById<View>(R.id.player_preroll_video_host)?.animate()?.cancel()
castJob?.cancel()
castPersonJob?.cancel()
subtitleSearchJob?.cancel()
nextUpJob?.cancel()
creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel()
retryJob?.cancel()
stablePlaybackJob?.cancel()
prolongedRebufferJob?.cancel()
playbackIdentityHideJob?.cancel()
playbackIdentityView?.animate()?.cancel()
loadingAnimator?.cancel()
@@ -3891,6 +4136,7 @@ class PlayerActivity : ComponentActivity() {
private const val NO_TRACE = -1
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
private const val PROLONGED_REBUFFER_RECOVERY_MS = 12_000L
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
private fun playbackStateName(state: Int): String =
@@ -3949,10 +4195,15 @@ internal fun prerollCanHandOff(
lifecycleStarted: Boolean,
): Boolean = active && minimumElapsed && lifecycleStarted
/** Preroll intentionally pauses on its first frame, so readiness—not isPlaying—drives time. */
/** The caller supplies either the local clip's playing state or the legacy frame's readiness. */
internal fun prerollCountdownAdvances(lifecycleStarted: Boolean, playbackReady: Boolean): Boolean =
lifecycleStarted && playbackReady
/** A valid packaged clip owns the gate; the server duration remains the failure fallback. */
internal fun effectivePrerollDurationMs(clipDurationMs: Long, configuredDurationMs: Long): Long =
clipDurationMs.takeIf { it > 0L && it != C.TIME_UNSET }
?: configuredDurationMs.coerceAtLeast(1L)
/** A positive position means the viewer is continuing something already started. */
internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): Boolean =
enabled && resumePositionMs <= 0L
@@ -0,0 +1,107 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.annotation.MainThread
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.ponzischeme89.memby.R
/**
* Keeps Memby's short local preroll prepared between playback sessions.
*
* Application start only registers an idle callback. Player construction and local-file
* preparation therefore begin after the launcher's queued start-up work has drained, and
* never block [android.app.Application.onCreate] or a home request. The same player is
* sought back to the beginning and prepared again after use rather than reconstructed for
* every title.
*/
@UnstableApi
internal object PrerollPreloader {
private val mainHandler = Handler(Looper.getMainLooper())
private var applicationContext: Context? = null
private var preloadScheduled = false
private var cachedPlayer: ExoPlayer? = null
fun start(context: Context) {
applicationContext = context.applicationContext
mainHandler.post(::scheduleAtMainQueueIdle)
}
private fun scheduleAtMainQueueIdle() {
if (preloadScheduled || cachedPlayer?.playbackState == ExoPlayer.STATE_READY) return
preloadScheduled = true
Looper.myQueue().addIdleHandler {
preloadScheduled = false
val context = applicationContext
if (context != null) {
val cached = cachedPlayer
if (cached == null) {
cachedPlayer = createPreparedPlayer(context)
} else if (cached.playbackState == ExoPlayer.STATE_IDLE) {
cached.prepare()
}
}
false
}
}
/** Takes ownership of the prepared player until [recycle] or [discard] is called. */
@MainThread
fun acquire(context: Context): ExoPlayer {
checkMainThread()
applicationContext = context.applicationContext
val prepared = cachedPlayer
cachedPlayer = null
if (prepared != null && prepared.playerError == null) return prepared
prepared?.release()
return createPreparedPlayer(context.applicationContext)
}
/**
* Returns a healthy player to the process cache without holding a second decoder while
* the requested programme is playing. [start] prepares it again when Home next opens.
*/
@MainThread
fun recycle(player: ExoPlayer) {
checkMainThread()
if (player.playerError != null) {
discard(player)
return
}
player.playWhenReady = false
player.pause()
player.stop()
player.seekTo(0L)
cachedPlayer?.release()
cachedPlayer = player
}
/** Drops a failed or otherwise unusable instance; the next Home/start call replaces it. */
@MainThread
fun discard(player: ExoPlayer) {
checkMainThread()
if (cachedPlayer === player) cachedPlayer = null
player.release()
}
private fun createPreparedPlayer(context: Context): ExoPlayer =
PlayerEngine.create(context).apply {
setMediaItem(
MediaItem.fromUri(
Uri.parse("android.resource://${context.packageName}/${R.raw.emby_preroll}"),
),
)
playWhenReady = false
prepare()
}
private fun checkMainThread() {
check(Looper.myLooper() == Looper.getMainLooper()) {
"The preroll player must be transferred on the main thread"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A restrained memorial marker: recognisable at TV distance without turning a cast
card into an obituary badge. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFB9C1C8"
android:pathData="M9,2h6v4h3v5h-3v11H9V11H6V6h3z" />
</vector>
@@ -23,9 +23,10 @@
android:paddingStart="48dp"
android:paddingTop="20dp"
android:paddingEnd="48dp"
android:paddingBottom="34dp">
android:paddingBottom="54dp">
<TextView
android:id="@+id/player_cast_eyebrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
@@ -78,7 +79,16 @@
android:orientation="horizontal" />
</HorizontalScrollView>
<LinearLayout
android:id="@+id/player_cast_profile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:visibility="gone" />
<TextView
android:id="@+id/player_cast_back_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
Binary file not shown.
+7
View File
@@ -27,6 +27,13 @@
<string name="player_cast_eyebrow">CAST</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_cast_profile_eyebrow">CAST PROFILE</string>
<string name="player_cast_profile_loading">Loading biography and filmography…</string>
<string name="player_cast_biography_empty">No biography is available.</string>
<string name="player_cast_filmography">FILMOGRAPHY</string>
<string name="player_cast_filmography_empty">No films or series are available.</string>
<string name="player_cast_back_to_cast">BACK · CAST</string>
<string name="player_cast_deceased">Deceased</string>
<string name="player_subtitle_track">SUBTITLES</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_subtitle_download">GET SUBTITLES</string>
@@ -97,5 +97,25 @@ class CastMetadataTest {
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
}
@Test
fun `decodes person biography and life dates`() {
val person = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
"""
{
"Id":"person-1",
"Name":"Alex Actor",
"Type":"Person",
"Overview":"A stage and screen performer.",
"PremiereDate":"1940-01-02T00:00:00.0000000Z",
"EndDate":"2020-03-04T00:00:00.0000000Z"
}
""".trimIndent(),
)
assertEquals("A stage and screen performer.", person.overview)
assertEquals("1940-01-02T00:00:00.0000000Z", person.premiereDate)
assertEquals("2020-03-04T00:00:00.0000000Z", person.endDate)
}
private fun actor(id: String) = EmbyPerson(id = id, name = id, type = "Actor")
}
@@ -3,6 +3,9 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import com.ponzischeme89.memby.data.model.DeviceProfile
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -45,4 +48,14 @@ class DevicePlaybackCapabilitiesTest {
assertTrue("video_h264_decode" in tokens)
assertTrue("video_hevc_decode" !in tokens)
}
@Test
fun decoderFallbackCannotReturnHevcStreamCopy() {
val fallback = DeviceProfile.embyAndroidTv(supportsHevc = true)
.h264TranscodeFallback()
assertTrue(fallback.directPlayProfiles.isEmpty())
assertEquals(listOf("h264"), fallback.transcodingProfiles.map { it.videoCodec })
assertTrue(fallback.codecProfiles.none { it.codec.equals("hevc", ignoreCase = true) })
}
}
@@ -0,0 +1,44 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.launchResumePositionMs
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import org.junit.Assert.assertEquals
import org.junit.Test
class ContinueWatchingResumeTest {
@Test
fun `detail metadata keeps the continue watching playhead and resume label`() {
val card = BaseItem(
id = "episode",
name = "The Episode",
type = "Episode",
indexNumber = 3,
parentIndexNumber = 1,
userData = UserItemData(playbackPositionTicks = 36_000_000L),
)
val detailsWithoutUserData = card.copy(
overview = "The richer record fetched after focus.",
userData = null,
)
val focused = focusedItemWithMetadata(card, detailsWithoutUserData)
assertEquals(36_000_000L, focused.userData?.playbackPositionTicks)
assertEquals("Resume S01E03", primaryActionLabel(focused))
assertEquals("The richer record fetched after focus.", focused.overview)
}
@Test
fun `pressed card resume point outranks a prefetched zero`() {
assertEquals(
36_000L,
launchResumePositionMs(resolvedPositionMs = 0L, requestedPositionMs = 36_000L),
)
assertEquals(
42_000L,
launchResumePositionMs(resolvedPositionMs = 42_000L, requestedPositionMs = 0L),
)
}
}
@@ -5,6 +5,13 @@ import org.junit.Assert.assertNull
import org.junit.Test
class AlertsFormatTest {
@Test
fun `dismissed alert hands focus to a stable neighbour`() {
assertEquals(1, alertFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
assertEquals(2, alertFocusIndexAfterRemoval(removedIndex = 3, remainingCount = 3))
assertNull(alertFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0))
}
@Test
fun `no alerts wears no badge`() {
assertNull(alertBadgeLabel(0))
@@ -43,7 +43,11 @@ class CastPanelScreenshotTest {
title = "The Lives of Others",
loaded = true,
members = listOf(
CastMember("Ulrich Mühe", "Hauptmann Gerd Wiesler"),
CastMember(
"Ulrich Mühe",
"Hauptmann Gerd Wiesler",
deathDate = "2007-07-22T00:00:00.0000000Z",
),
CastMember("Martina Gedeck", "Christa-Maria Sieland"),
CastMember("Sebastian Koch", "Georg Dreyman"),
CastMember("Ulrich Tukur", "Oberstleutnant Anton Grubitz"),
@@ -94,6 +98,33 @@ class CastPanelScreenshotTest {
)
}
@Test
fun `selected actor shows biography and filmography`() {
capture(
name = "cast-panel-person-profile",
state = CastPanelState(
title = "The Lives of Others",
loaded = true,
members = listOf(CastMember("Ulrich Mühe", id = "person-1")),
selectedPerson = CastPersonPanel(
id = "person-1",
name = "Ulrich Mühe",
role = "Hauptmann Gerd Wiesler",
birthDate = "1953-06-20T00:00:00.0000000Z",
deathDate = "2007-07-22T00:00:00.0000000Z",
overview = "A celebrated German actor known for quiet, exacting performances on stage and screen. His portrayal of Gerd Wiesler earned international acclaim.",
filmography = listOf(
FilmographyCredit("The Lives of Others", 2006),
FilmographyCredit("Funny Games", 1997),
FilmographyCredit("The Castle", 1997),
FilmographyCredit("Benny's Video", 1992),
),
loaded = true,
),
),
)
}
/** Still fetching. The panel opens instantly and says so rather than showing nothing. */
@Test
fun `still loading`() {
@@ -118,6 +149,10 @@ class CastPanelScreenshotTest {
assertEquals("C", castInitials("Cher"))
assertEquals("AA", castInitials(" amy adams "))
assertEquals("", castInitials(" "))
assertEquals(
"20 June 1953 22 July 2007",
personLifeDates("1953-06-20T00:00:00Z", "2007-07-22T00:00:00Z"),
)
}
private fun capture(name: String, state: CastPanelState, focused: Int? = null) {
@@ -53,4 +53,13 @@ class PlaybackRecoveryTest {
assertEquals(3_000L, automaticRetryDelayMs(2))
assertNull(automaticRetryDelayMs(3))
}
@Test
fun prolongedMidProgrammeRebufferGetsOneCompatibilityFallback() {
assertTrue(shouldRecoverProlongedRebuffer(true, false, false, false))
assertFalse(shouldRecoverProlongedRebuffer(false, false, false, false))
assertFalse(shouldRecoverProlongedRebuffer(true, true, false, false))
assertFalse(shouldRecoverProlongedRebuffer(true, false, true, false))
assertFalse(shouldRecoverProlongedRebuffer(true, false, false, true))
}
}
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.player
import androidx.media3.common.C
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -40,4 +42,11 @@ class PrerollSequenceTest {
assertFalse(prerollCountdownAdvances(lifecycleStarted = false, playbackReady = true))
assertFalse(prerollCountdownAdvances(lifecycleStarted = true, playbackReady = false))
}
@Test
fun `packaged clip duration replaces the configured fallback`() {
assertEquals(4_133L, effectivePrerollDurationMs(4_133L, 7_000L))
assertEquals(7_000L, effectivePrerollDurationMs(C.TIME_UNSET, 7_000L))
assertEquals(7_000L, effectivePrerollDurationMs(0L, 7_000L))
}
}
+29 -24
View File
@@ -18,20 +18,21 @@ focus moves predictably, and whether destructive or app-leaving actions are reco
dismisses the confirmation, and Left/Right is explicitly constrained between the two
choices. The setting defaults off and is stored per television.
## Open findings
## Fixed locally after 0.2.39 — not published
### NAV-002 — Recommendation onboarding has no deterministic entry focus or Back route
- Severity: High
- Surface: First-time recommendation onboarding
- Reproduction: Sign in with a profile that has been prompted for taste onboarding.
- Finding: The screen creates focusable title/person cards and footer buttons but does not
request initial focus. It also has no `BackHandler`. Focus therefore depends on Compose's
geometric fallback, while Back can leave the activity instead of moving to a defined
- Previous behaviour: The screen created focusable title/person cards and footer buttons but did not
request initial focus. It also had no `BackHandler`. Focus therefore depended on Compose's
geometric fallback, while Back could leave the activity instead of moving to a defined
previous step or presenting a skip choice.
- Recommended fix: Give the first card (or **Next** when the stage is empty) an explicit
entry requester. Make Back move to the previous stage; on the first stage, focus a clear
**Skip for now** confirmation instead of closing the app.
- Resolution: The first choice now receives focus explicitly; an empty stage focuses its
primary footer action. Back moves to the previous stage, while Back on the first stage
opens a Cancel-first **Skip for now** confirmation. Skipping returns to Home for this
visit without falsely recording onboarding as complete.
### NAV-003 — Manage users does not scale beyond one fixed row
@@ -39,10 +40,11 @@ focus moves predictably, and whether destructive or app-leaving actions are reco
- Surface: Manage users / profile chooser
- Reproduction: Save enough profiles that the fixed 154 dp tiles plus 24 dp gaps exceed the
viewport width.
- Finding: Profiles are rendered in a plain `Row`, not a `LazyRow`, and do not wrap or
- Previous behaviour: Profiles were rendered in a plain `Row`, not a `LazyRow`, and did not wrap or
scroll. Profiles beyond the right edge can become invisible or unreachable by D-pad.
- Recommended fix: Use a centred `LazyRow` with content padding, stable keys and remembered
horizontal state. Keep **Add another user** as the final item.
- Resolution: Profiles now use a padded `LazyRow` with stable profile keys, so focus brings
off-screen profiles into view. **Add another user** remains the final item and has
explicit neighbours.
### NAV-004 — Profile removal targets rely on geometric focus search
@@ -50,38 +52,41 @@ focus moves predictably, and whether destructive or app-leaving actions are reco
- Surface: Manage users / profile chooser
- Reproduction: Move around a profile tile and its small × removal control using only the
D-pad.
- Finding: Each tile and its overlaid removal button are independent focus targets with no
- Previous behaviour: Each tile and its overlaid removal button were independent focus targets with no
explicit direction mapping. Depending on card position and screen density, Up/Right can
select a different profile or skip the removal target. The 28 dp target is also difficult
to identify at TV distance.
- Recommended fix: Put profile actions in a deterministic vertical group: OK opens the
profile and a labelled **Remove user** action sits below it, with explicit Up/Down and
Left/Right neighbours. Retain the existing safe Cancel-first confirmation.
- Resolution: Every profile is now a deterministic two-action column: the profile tile and
a full-width, labelled **Remove user** action. Explicit Up/Down and Left/Right mappings
connect peer actions, **Add another user**, and **Back to Memby**. The existing
Cancel-first removal confirmation remains.
### NAV-005 — Dismissing an alert can leave focus without a stable successor
- Severity: Medium
- Surface: My Alerts
- Reproduction: Focus an alert in the middle of the list and press OK to dismiss it.
- Finding: The focused keyed row is removed immediately, but focus is only requested when
- Previous behaviour: The focused keyed row was removed immediately, but focus was only requested when
the list changes between empty and non-empty. There is no requester for the next or
previous alert after an individual removal, so focus restoration is left to framework
behaviour and can appear to vanish on some Compose/TV combinations.
- Recommended fix: Track the focused alert id/index and, after removal, request the item now
occupying that index (or the previous item). When the list becomes empty, move focus to
the alert controls explicitly.
previous alert after an individual removal, so focus restoration was left to framework
behaviour and could appear to vanish on some Compose/TV combinations.
- Resolution: The page records the dismissed row index, scrolls the remaining list to the
row now occupying that position, and requests it after composition. Removing the final
alert explicitly returns focus to the alert controls. The index rule is unit-tested.
### NAV-006 — Onboarding stage changes do not preserve a clear focus destination
- Severity: Medium
- Surface: Recommendation onboarding
- Reproduction: Focus **Next**, advance a stage, then continue with the D-pad.
- Finding: The focused footer node is reused while the card row above is replaced. The
- Previous behaviour: The focused footer node was reused while the card row above was replaced. The
viewer is left at the bottom of each new question and must navigate geometrically back to
the new choices; there is no explicit Up target or per-stage return position.
- Recommended fix: On each stage change, move focus to the first choice and remember the
last focused choice per stage. Give the footer buttons explicit Up targets and the card
row an explicit Down target.
- Resolution: Every stage change now places focus on the first choice, making the new
question immediately actionable. Choice cards explicitly move Down to the primary footer
action; footer actions explicitly move Up to the choices and Left/Right between each
other. This deliberately resets to the first choice rather than leaving the remote on a
stale footer node from the previous question.
## Areas checked with no blocking issue found
+2
View File
@@ -168,6 +168,8 @@ func (s *Server) Routes() http.Handler {
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/people/{id}", s.authed(s.handlePerson))
v1.Handle("GET /v1/people/{id}/filmography", s.authed(s.handlePersonFilmography))
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
+94
View File
@@ -0,0 +1,94 @@
package api
import (
"encoding/json"
"net/http"
"net/url"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
type personFilmographyResponse struct {
Items []json.RawMessage `json:"items"`
}
// handlePerson returns Emby's person item. PremiereDate and EndDate are the person's
// birth and death dates; Overview is their biography.
func (s *Server) handlePerson(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
personID := r.PathValue("id")
if personID == "" {
writeError(w, http.StatusBadRequest, "person id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "person:v1:"+personID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
person, err := s.emby.Item(
ctx,
credentials(sess),
personID,
"Overview,Genres,PrimaryImageAspectRatio",
)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the person")
return
}
if err := s.cache.Set(ctx, key, person, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("person cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, person)
}
// Filmography is kept separate from the biography so life dates can decorate the cast
// row without also loading every cast member's credits.
func (s *Server) handlePersonFilmography(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
personID := r.PathValue("id")
if personID == "" {
writeError(w, http.StatusBadRequest, "person id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "person-filmography:v1:"+personID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
result, err := s.emby.Items(ctx, credentials(sess), url.Values{
"PersonIds": {personID},
"IncludeItemTypes": {"Movie,Series"},
"Recursive": {"true"},
"SortBy": {"ProductionYear,SortName"},
"SortOrder": {"Descending"},
"Limit": {"60"},
"Fields": {"Overview,ProductionYear,PrimaryImageAspectRatio"},
"EnableImages": {"true"},
"EnableImageTypes": {"Primary"},
"ImageTypeLimit": {"1"},
"EnableUserData": {"true"},
})
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the person's filmography")
return
}
items := result.Items
if items == nil {
items = []json.RawMessage{}
}
body, err := json.Marshal(personFilmographyResponse{Items: items})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode the filmography")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("person filmography cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
+6 -6
View File
@@ -297,12 +297,16 @@ func (c *Client) PlaybackInfo(
"UserId": {cred.UserID},
"IsPlayback": {"true"},
}
profile := androidTVDeviceProfile(capabilities)
if forceTranscode {
forceH264TranscodeProfile(profile)
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
"DeviceProfile": androidTVDeviceProfile(capabilities),
"AllowVideoStreamCopy": !forceTranscode, "AllowAudioStreamCopy": true,
"DeviceProfile": profile,
})
var requestBody map[string]any
if err == nil {
@@ -314,10 +318,6 @@ func (c *Client) PlaybackInfo(
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if forceTranscode {
profile := requestBody["DeviceProfile"].(map[string]any)
profile["DirectPlayProfiles"] = []map[string]string{}
}
if err == nil {
body, err = json.Marshal(requestBody)
}
@@ -32,3 +32,23 @@ func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
t.Fatalf("codec profiles = %#v", codecProfiles)
}
}
func TestForcedTranscodeProfileCannotStreamCopyHEVC(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
HEVC: true, HEVCMain: true, HEVCMain10: true,
})
forceH264TranscodeProfile(profile)
if direct := profile["DirectPlayProfiles"].([]map[string]string); len(direct) != 0 {
t.Fatalf("direct profiles = %#v", direct)
}
transcode := profile["TranscodingProfiles"].([]map[string]string)
if len(transcode) != 1 || transcode[0]["VideoCodec"] != "h264" {
t.Fatalf("transcoding profiles = %#v", transcode)
}
for _, codecProfile := range profile["CodecProfiles"].([]map[string]any) {
if codecProfile["Codec"] == "hevc" {
t.Fatalf("HEVC constraint survived fallback: %#v", codecProfile)
}
}
}
+21
View File
@@ -53,6 +53,27 @@ func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
}
}
// forceH264TranscodeProfile turns decoder recovery into an actual codec change. Merely
// removing DirectPlayProfiles is insufficient: Emby may otherwise stream-copy HEVC into
// the ordinary HLS transcoding profile and hand the failing decoder the same video again.
func forceH264TranscodeProfile(profile map[string]any) {
profile["DirectPlayProfiles"] = []map[string]string{}
profile["TranscodingProfiles"] = []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
}
profiles, _ := profile["CodecProfiles"].([]map[string]any)
h264Profiles := make([]map[string]any, 0, len(profiles))
for _, codecProfile := range profiles {
if codec, _ := codecProfile["Codec"].(string); codec == "h264" {
h264Profiles = append(h264Profiles, codecProfile)
}
}
profile["CodecProfiles"] = h264Profiles
}
func androidTVSubtitleProfiles() []map[string]string {
return []map[string]string{
{"Format": "srt", "Method": "External"},