diff --git a/CHANGELOG.md b/CHANGELOG.md index ce206b8..035ee70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 84975cc..cafffeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 20–200 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt index 7117108..1cef329 100644 --- a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt +++ b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt @@ -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) } /** diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index b0925ad..022bf74 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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() + private val filmographyCache = java.util.concurrent.ConcurrentHashMap>() + + /** 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 { + 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>() /** 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) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index afe5d22..4125f30 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -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 = emptyList(), diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 18b96f2..f11642a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 4fb2a5e..9f35116 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 61a5bb3..5635aae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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, 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(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, List, List, List) -> 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(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, ratings: MutableMap, + 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, selectedPeople: MutableMap, + 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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index a3ded13..4fbd1c3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -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(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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt index b372b0d..82ffb07 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt @@ -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 = emptyList(), + val loaded: Boolean = false, ) /** @@ -32,6 +52,7 @@ data class CastPanelState( val title: String = "", val members: List = 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(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(R.id.player_cast_title).apply { - text = state.title - isVisible = state.title.isNotBlank() + text = selected?.name ?: state.title + isVisible = !text.isNullOrBlank() } overlay.findViewById(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(R.id.player_cast_people) people.removeAllViews() - state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) } - overlay.findViewById(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty() + state.members.forEach { member -> + people.addView(castCard(context, member, loadImage, onMemberClick)) + } + overlay.findViewById(R.id.player_cast_scroller).isVisible = + selected == null && state.members.isNotEmpty() + overlay.findViewById(R.id.player_cast_profile).apply { + isVisible = selected?.loaded == true + removeAllViews() + selected?.takeIf { it.loaded }?.let { bindPersonProfile(this, it, loadImage) } + } + overlay.findViewById(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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt index 05543e7..76034ff 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackRecovery.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 5f9cd63..ce146a0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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 = emptyList() private var castLoaded = false + private var castProfiles: Map = 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(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(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(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(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(R.id.player_cast_people) - .getChildAt(0) - ?.requestFocus() - ?: overlay.requestFocus() + if (selectedCastPerson == null) { + overlay.findViewById(R.id.player_cast_people) + .getChildAt(0) + ?.requestFocus() + ?: overlay.requestFocus() + } else { + overlay.findViewById(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(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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt new file mode 100644 index 0000000..98980c1 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -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" + } + } +} diff --git a/app/src/main/res/drawable/ic_deceased.xml b/app/src/main/res/drawable/ic_deceased.xml new file mode 100644 index 0000000..2b7ce3c --- /dev/null +++ b/app/src/main/res/drawable/ic_deceased.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/player_cast_overlay.xml b/app/src/main/res/layout/player_cast_overlay.xml index 960d37c..6c2edf7 100644 --- a/app/src/main/res/layout/player_cast_overlay.xml +++ b/app/src/main/res/layout/player_cast_overlay.xml @@ -23,9 +23,10 @@ android:paddingStart="48dp" android:paddingTop="20dp" android:paddingEnd="48dp" - android:paddingBottom="34dp"> + android:paddingBottom="54dp"> + + CAST Loading cast… No cast information is available. + CAST PROFILE + Loading biography and filmography… + No biography is available. + FILMOGRAPHY + No films or series are available. + BACK · CAST + Deceased SUBTITLES TEXT SIZE GET SUBTITLES diff --git a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt index 31e612c..dcad6b3 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt @@ -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( + """ + { + "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") } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt index c3d0c55..3395534 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/DevicePlaybackCapabilitiesTest.kt @@ -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) }) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt new file mode 100644 index 0000000..9729cde --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt index 6e26d0a..7313d0b 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt @@ -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)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt index d714c3a..117ddf8 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt @@ -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) { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt index 9d1331e..82cee08 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackRecoveryTest.kt @@ -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)) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt index cdaa303..9155cb5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollSequenceTest.kt @@ -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)) + } } diff --git a/bugs-0908.md b/bugs-0908.md index b567c5f..3fda2a9 100644 --- a/bugs-0908.md +++ b/bugs-0908.md @@ -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 diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 0a04e43..5f1cc3b 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/people.go b/server/internal/api/people.go new file mode 100644 index 0000000..a733ff2 --- /dev/null +++ b/server/internal/api/people.go @@ -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) +} diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index 9643bb1..0796b49 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -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) } diff --git a/server/internal/emby/client_playback_test.go b/server/internal/emby/client_playback_test.go index 24ef8a0..e1a272c 100644 --- a/server/internal/emby/client_playback_test.go +++ b/server/internal/emby/client_playback_test.go @@ -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) + } + } +} diff --git a/server/internal/emby/device_profile.go b/server/internal/emby/device_profile.go index f9aa379..901a865 100644 --- a/server/internal/emby/device_profile.go +++ b/server/internal/emby/device_profile.go @@ -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"},