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
@@ -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))
}
}