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