Improve playback, preroll and TV experience
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
## 0.2.39 — 2026-08-09
|
## 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.
|
- 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.
|
- 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
|
## 0.2.38 — 2026-08-09
|
||||||
- Improved: Ratings are larger and easier to read.
|
- 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
|
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.
|
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
|
**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
|
`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
|
→ 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.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||||
|
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ class MembyApp : Application() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
ServiceLocator.init(this)
|
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.PlaybackReport
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
||||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
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.EmbyApi
|
||||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
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>>()
|
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
|
/** 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
|
* 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
|
* 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.
|
* 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
|
// 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
|
// 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.
|
// fallback — letting the player await it — is what should happen then anyway.
|
||||||
if (!playableMutex.tryLock()) return null
|
if (!playableMutex.tryLock()) return null
|
||||||
try {
|
try {
|
||||||
val entry = playableCache[itemId] ?: return null
|
val entry = playableCache[request.itemId] ?: return null
|
||||||
if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
|
if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
|
||||||
// Consumed: negotiated session state must never be handed out twice.
|
// Consumed: negotiated session state must never be handed out twice.
|
||||||
playableCache.remove(itemId)
|
playableCache.remove(request.itemId)
|
||||||
return entry.playable
|
return entry.playable.copy(
|
||||||
|
resumePositionMs = launchResumePositionMs(
|
||||||
|
resolvedPositionMs = entry.playable.resumePositionMs,
|
||||||
|
requestedPositionMs = request.resumePositionMs,
|
||||||
|
),
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
playableMutex.unlock()
|
playableMutex.unlock()
|
||||||
}
|
}
|
||||||
@@ -1444,9 +1502,22 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
?.playable
|
?.playable
|
||||||
ready to if (ready == null) playableInFlight[request.itemId] else null
|
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) {
|
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) }
|
playableMutex.withLock { playableCache.remove(request.itemId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2138,13 +2209,17 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
startTimeTicks = millisecondsToTicks(positionMs),
|
startTimeTicks = millisecondsToTicks(positionMs),
|
||||||
enableDirectPlay = !forceTranscode,
|
enableDirectPlay = !forceTranscode,
|
||||||
enableDirectStream = !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,
|
subtitleStreamIndex = subtitleStreamIndex,
|
||||||
currentPlaySessionId = currentPlaySessionId,
|
currentPlaySessionId = currentPlaySessionId,
|
||||||
deviceProfile = if (forceTranscode) {
|
deviceProfile = if (forceTranscode) {
|
||||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||||
capabilities = devicePlaybackCapabilities,
|
capabilities = devicePlaybackCapabilities,
|
||||||
)
|
)
|
||||||
.copy(directPlayProfiles = emptyList())
|
.h264TranscodeFallback()
|
||||||
} else {
|
} else {
|
||||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||||
capabilities = devicePlaybackCapabilities,
|
capabilities = devicePlaybackCapabilities,
|
||||||
@@ -2558,6 +2633,10 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
|
|||||||
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
|
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
|
||||||
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
|
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
|
private val BaseItem.resumePositionMs: Long
|
||||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
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
|
@Serializable
|
||||||
data class CodecProfile(
|
data class CodecProfile(
|
||||||
@SerialName("Type") val type: String = "Video",
|
@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
|
// 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.
|
// for by name in every episode query — it is not a default field.
|
||||||
@SerialName("PremiereDate") val premiereDate: String? = null,
|
@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("OfficialRating") val officialRating: String? = null,
|
||||||
@SerialName("CommunityRating") val communityRating: Double? = null,
|
@SerialName("CommunityRating") val communityRating: Double? = null,
|
||||||
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ interface GatewayApi {
|
|||||||
@GET("v1/items/{id}")
|
@GET("v1/items/{id}")
|
||||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
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. */
|
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
|
||||||
@GET("v1/items/{id}/ratings")
|
@GET("v1/items/{id}/ratings")
|
||||||
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
|
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
|
||||||
|
|||||||
@@ -307,12 +307,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
*/
|
*/
|
||||||
fun focusItem(item: BaseItem) {
|
fun focusItem(item: BaseItem) {
|
||||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
||||||
val focused = (cached ?: item).copy(
|
val focused = focusedItemWithMetadata(item, cached)
|
||||||
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
|
|
||||||
membyRecommendationReason = item.membyRecommendationReason
|
|
||||||
?: cached?.membyRecommendationReason,
|
|
||||||
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
|
|
||||||
)
|
|
||||||
_focusedItem.value = focused
|
_focusedItem.value = focused
|
||||||
metadataJob?.cancel()
|
metadataJob?.cancel()
|
||||||
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
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,
|
// Play click a memory lookup. The repository single-flights requests,
|
||||||
// so focus and click can never duplicate the gateway call.
|
// so focus and click can never duplicate the gateway call.
|
||||||
if (item.membyPlayable) {
|
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
|
// Warm the explanation and franchise siblings while the card is already
|
||||||
// focused, so opening Details does not add a reason line a frame later.
|
// 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 {
|
val details = runCatching {
|
||||||
repository.getItemDetails(item.id)
|
repository.getItemDetails(item.id)
|
||||||
}.getOrNull() ?: return@launch
|
}.getOrNull() ?: return@launch
|
||||||
val taggedDetails = details.copy(
|
val taggedDetails = focusedItemWithMetadata(item, details)
|
||||||
membyAiringToday = focused.membyAiringToday,
|
|
||||||
membyRecommendationReason = focused.membyRecommendationReason,
|
|
||||||
membyCompatibility = focused.membyCompatibility,
|
|
||||||
)
|
|
||||||
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
|
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
|
||||||
if (_focusedItem.value?.id == item.id) {
|
if (_focusedItem.value?.id == item.id) {
|
||||||
_focusedItem.value = taggedDetails
|
_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 {
|
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
|
||||||
val airingTodayKeys = rows.airingTodayShowKeys()
|
val airingTodayKeys = rows.airingTodayShowKeys()
|
||||||
if (airingTodayKeys.isEmpty()) return this
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
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.GenreDiscoveryStrip
|
||||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||||
|
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||||
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
import com.ponzischeme89.memby.performance.PerformanceMonitor
|
||||||
import com.ponzischeme89.memby.ui.search.SearchScreen
|
import com.ponzischeme89.memby.ui.search.SearchScreen
|
||||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||||
@@ -211,6 +213,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
PerformanceMonitor.start(this)
|
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 -> {
|
recommendationOnboarding?.prompted == true -> {
|
||||||
RecommendationOnboardingScreen(
|
RecommendationOnboardingScreen(
|
||||||
onboarding = recommendationOnboarding!!,
|
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 ->
|
onComplete = { ratings, actors, actresses, directors ->
|
||||||
repo.saveRecommendationRatings(ratings, actors, actresses, directors)
|
repo.saveRecommendationRatings(ratings, actors, actresses, directors)
|
||||||
// Record it locally as well, so this profile's next cold start
|
// Record it locally as well, so this profile's next cold start
|
||||||
@@ -459,6 +474,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun ExitMembyConfirmation(
|
private fun ExitMembyConfirmation(
|
||||||
onStay: () -> Unit,
|
onStay: () -> Unit,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
@@ -502,13 +518,23 @@ private fun ExitMembyConfirmation(
|
|||||||
onClick = onStay,
|
onClick = onStay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(stayFocus)
|
.focusRequester(stayFocus)
|
||||||
.focusProperties { right = exitFocus },
|
.focusProperties {
|
||||||
|
left = FocusRequester.Cancel
|
||||||
|
right = exitFocus
|
||||||
|
up = FocusRequester.Cancel
|
||||||
|
down = FocusRequester.Cancel
|
||||||
|
},
|
||||||
) { Text("Stay in Memby") }
|
) { Text("Stay in Memby") }
|
||||||
Button(
|
Button(
|
||||||
onClick = onExit,
|
onClick = onExit,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(exitFocus)
|
.focusRequester(exitFocus)
|
||||||
.focusProperties { left = stayFocus },
|
.focusProperties {
|
||||||
|
left = stayFocus
|
||||||
|
right = FocusRequester.Cancel
|
||||||
|
up = FocusRequester.Cancel
|
||||||
|
down = FocusRequester.Cancel
|
||||||
|
},
|
||||||
) { Text("Close Memby") }
|
) { Text("Close Memby") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,6 +908,7 @@ private fun ProfileEntryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun ProfileChooser(
|
private fun ProfileChooser(
|
||||||
profiles: List<EmbyProfile>,
|
profiles: List<EmbyProfile>,
|
||||||
currentProfileId: String?,
|
currentProfileId: String?,
|
||||||
@@ -892,13 +919,18 @@ private fun ProfileChooser(
|
|||||||
onAddProfile: () -> Unit,
|
onAddProfile: () -> Unit,
|
||||||
onClose: (() -> Unit)?,
|
onClose: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val firstFocus = remember { FocusRequester() }
|
val addFocus = remember { FocusRequester() }
|
||||||
|
val backFocus = remember { FocusRequester() }
|
||||||
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
|
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
|
||||||
val orderedProfiles = remember(profiles, currentProfileId) {
|
val orderedProfiles = remember(profiles, currentProfileId) {
|
||||||
profiles.sortedByDescending { it.id == currentProfileId }
|
profiles.sortedByDescending { it.id == currentProfileId }
|
||||||
}
|
}
|
||||||
LaunchedEffect(orderedProfiles.firstOrNull()?.id) {
|
val profileIds = orderedProfiles.map(EmbyProfile::id)
|
||||||
firstFocus.requestFocus()
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -929,47 +961,81 @@ private fun ProfileChooser(
|
|||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
modifier = Modifier.padding(top = 8.dp, bottom = 30.dp),
|
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),
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
orderedProfiles.forEachIndexed { index, profile ->
|
itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile ->
|
||||||
Box(modifier = Modifier.width(154.dp)) {
|
Column(
|
||||||
|
modifier = Modifier.width(154.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
ProfileTile(
|
ProfileTile(
|
||||||
name = profile.username,
|
name = profile.username,
|
||||||
current = profile.id == currentProfileId,
|
current = profile.id == currentProfileId,
|
||||||
enabled = switchingProfileId == null && removingProfileId == null,
|
enabled = switchingProfileId == null && removingProfileId == null,
|
||||||
onClick = { onSelect(profile) },
|
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(
|
ProfileDeleteButton(
|
||||||
profileName = profile.username,
|
profileName = profile.username,
|
||||||
enabled = switchingProfileId == null && removingProfileId == null,
|
enabled = switchingProfileId == null && removingProfileId == null,
|
||||||
onClick = { pendingRemoval = profile },
|
onClick = { pendingRemoval = profile },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.focusRequester(removeFocus[index])
|
||||||
.padding(top = 7.dp, end = 22.dp),
|
.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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item(key = "add-profile") {
|
||||||
ProfileTile(
|
ProfileTile(
|
||||||
name = "Add another user",
|
name = "Add another user",
|
||||||
current = false,
|
current = false,
|
||||||
enabled = switchingProfileId == null && removingProfileId == null,
|
enabled = switchingProfileId == null && removingProfileId == null,
|
||||||
symbol = "+",
|
symbol = "+",
|
||||||
onClick = onAddProfile,
|
onClick = onAddProfile,
|
||||||
modifier = if (orderedProfiles.isEmpty()) {
|
modifier = Modifier
|
||||||
Modifier.focusRequester(firstFocus)
|
.focusRequester(addFocus)
|
||||||
} else {
|
.focusProperties {
|
||||||
Modifier
|
left = tileFocus.lastOrNull() ?: FocusRequester.Cancel
|
||||||
|
right = FocusRequester.Cancel
|
||||||
|
down = if (onClose != null) backFocus else FocusRequester.Cancel
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Spacer(Modifier.height(30.dp))
|
Spacer(Modifier.height(30.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onClose,
|
onClick = onClose,
|
||||||
enabled = switchingProfileId == null && removingProfileId == null,
|
enabled = switchingProfileId == null && removingProfileId == null,
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(backFocus)
|
||||||
|
.focusProperties {
|
||||||
|
up = removeFocus.firstOrNull() ?: addFocus
|
||||||
|
},
|
||||||
) { Text("Back to Memby") }
|
) { Text("Back to Memby") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -987,9 +1053,11 @@ private fun ProfileChooser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun RecommendationOnboardingScreen(
|
private fun RecommendationOnboardingScreen(
|
||||||
onboarding: RecommendationOnboarding,
|
onboarding: RecommendationOnboarding,
|
||||||
onComplete: suspend (Map<String, Int>, List<String>, List<String>, List<String>) -> Unit,
|
onComplete: suspend (Map<String, Int>, List<String>, List<String>, List<String>) -> Unit,
|
||||||
|
onSkip: () -> Unit = {},
|
||||||
initialStageIndex: Int = 0,
|
initialStageIndex: Int = 0,
|
||||||
previewArtwork: ImageBitmap? = null,
|
previewArtwork: ImageBitmap? = null,
|
||||||
) {
|
) {
|
||||||
@@ -1015,7 +1083,25 @@ private fun RecommendationOnboardingScreen(
|
|||||||
var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) }
|
var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var confirmingSkip by rememberSaveable { mutableStateOf(false) }
|
||||||
val stage = stages.getOrNull(stageIndex)
|
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 = {
|
val finish: () -> Unit = {
|
||||||
if (!saving) {
|
if (!saving) {
|
||||||
@@ -1095,11 +1181,21 @@ private fun RecommendationOnboardingScreen(
|
|||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
when (stage) {
|
when (stage) {
|
||||||
"Movies" -> OnboardingTitleRow(movies, ratings, previewArtwork)
|
"Movies" -> OnboardingTitleRow(
|
||||||
"TV shows" -> OnboardingTitleRow(shows, ratings, previewArtwork)
|
movies, ratings, choiceFocus, primaryFocus, previewArtwork,
|
||||||
"Actors" -> OnboardingPeopleRow(onboarding.actors, selectedActors, previewArtwork)
|
)
|
||||||
"Actresses" -> OnboardingPeopleRow(onboarding.actresses, selectedActresses, previewArtwork)
|
"TV shows" -> OnboardingTitleRow(
|
||||||
"Directors" -> OnboardingPeopleRow(onboarding.directors, selectedDirectors, previewArtwork)
|
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))
|
Spacer(Modifier.weight(1f))
|
||||||
@@ -1109,15 +1205,114 @@ private fun RecommendationOnboardingScreen(
|
|||||||
color = Color(0xFF9DA8B0), fontSize = 14.sp, modifier = Modifier.weight(1f),
|
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)) }
|
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))
|
Spacer(Modifier.width(10.dp))
|
||||||
if (stageIndex < stages.lastIndex) {
|
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 {
|
} 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(
|
private fun OnboardingTitleRow(
|
||||||
items: List<BaseItem>,
|
items: List<BaseItem>,
|
||||||
ratings: MutableMap<String, Int>,
|
ratings: MutableMap<String, Int>,
|
||||||
|
entryFocus: FocusRequester,
|
||||||
|
footerFocus: FocusRequester,
|
||||||
previewArtwork: ImageBitmap? = null,
|
previewArtwork: ImageBitmap? = null,
|
||||||
) {
|
) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
|
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
|
val selected = ratings[item.id] == 5
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 },
|
onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 },
|
||||||
contentDescription = "${item.name}${if (selected) ", selected" else ""}",
|
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 ->
|
) { focused ->
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Box(
|
Box(
|
||||||
@@ -1164,17 +1364,22 @@ private fun OnboardingTitleRow(
|
|||||||
private fun OnboardingPeopleRow(
|
private fun OnboardingPeopleRow(
|
||||||
people: List<RecommendationPerson>,
|
people: List<RecommendationPerson>,
|
||||||
selectedPeople: MutableMap<String, Boolean>,
|
selectedPeople: MutableMap<String, Boolean>,
|
||||||
|
entryFocus: FocusRequester,
|
||||||
|
footerFocus: FocusRequester,
|
||||||
previewArtwork: ImageBitmap? = null,
|
previewArtwork: ImageBitmap? = null,
|
||||||
) {
|
) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
|
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
|
val selected = selectedPeople[person.name] == true
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { selectedPeople[person.name] = !selected },
|
onClick = { selectedPeople[person.name] = !selected },
|
||||||
contentDescription = "${person.name}${if (selected) ", selected" else ""}",
|
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 ->
|
) { focused ->
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Box(
|
Box(
|
||||||
@@ -1253,11 +1458,12 @@ private fun ProfileDeleteButton(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
Box(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(28.dp)
|
.width(124.dp)
|
||||||
|
.height(34.dp)
|
||||||
.zIndex(2f)
|
.zIndex(2f)
|
||||||
.clip(CircleShape)
|
.clip(RoundedCornerShape(9.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
!enabled -> Color(0xFF343A3F)
|
!enabled -> Color(0xFF343A3F)
|
||||||
@@ -1268,29 +1474,32 @@ private fun ProfileDeleteButton(
|
|||||||
.border(
|
.border(
|
||||||
width = if (focused) 2.dp else 1.dp,
|
width = if (focused) 2.dp else 1.dp,
|
||||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.55f),
|
color = if (focused) Color.White else Color.White.copy(alpha = 0.55f),
|
||||||
shape = CircleShape,
|
shape = RoundedCornerShape(9.dp),
|
||||||
)
|
)
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(enabled = enabled, onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.semantics { contentDescription = "Remove $profileName" },
|
.semantics { contentDescription = "Remove $profileName" },
|
||||||
contentAlignment = Alignment.Center,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "×",
|
text = "Remove user",
|
||||||
color = if (enabled) Color.White else Color(0xFF899198),
|
color = if (enabled) Color.White else Color(0xFF899198),
|
||||||
fontSize = 20.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun ProfileRemovalConfirmation(
|
private fun ProfileRemovalConfirmation(
|
||||||
profile: EmbyProfile,
|
profile: EmbyProfile,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val cancelFocus = remember { FocusRequester() }
|
val cancelFocus = remember { FocusRequester() }
|
||||||
|
val removeFocus = remember { FocusRequester() }
|
||||||
BackHandler(onBack = onCancel)
|
BackHandler(onBack = onCancel)
|
||||||
LaunchedEffect(profile.id) { cancelFocus.requestFocus() }
|
LaunchedEffect(profile.id) { cancelFocus.requestFocus() }
|
||||||
Box(
|
Box(
|
||||||
@@ -1321,9 +1530,26 @@ private fun ProfileRemovalConfirmation(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onCancel,
|
onClick = onCancel,
|
||||||
modifier = Modifier.focusRequester(cancelFocus),
|
modifier = Modifier
|
||||||
|
.focusRequester(cancelFocus)
|
||||||
|
.focusProperties {
|
||||||
|
left = FocusRequester.Cancel
|
||||||
|
right = removeFocus
|
||||||
|
up = FocusRequester.Cancel
|
||||||
|
down = FocusRequester.Cancel
|
||||||
|
},
|
||||||
) { Text("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
|
// 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
|
// 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.
|
// 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 request = repo.playbackRequest(item)
|
||||||
|
val ready = repo.readyPlayableForLaunch(request)
|
||||||
if (ready == null && request.resumePositionMs > 0L) {
|
if (ready == null && request.resumePositionMs > 0L) {
|
||||||
playbackLauncher.launch(
|
playbackLauncher.launch(
|
||||||
PlayerActivity.intent(
|
PlayerActivity.intent(
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -92,17 +93,35 @@ fun MyAlertsPage(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listFocusRequester = remember { FocusRequester() }
|
|
||||||
val actionsFocusRequester = 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()
|
val hasAlerts = notifications.isNotEmpty()
|
||||||
LaunchedEffect(hasAlerts) {
|
LaunchedEffect(hasAlerts) {
|
||||||
// One frame for the list to place its first row; an empty page has nothing below
|
// 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.
|
// the actions to land on, so the chips take the remote instead.
|
||||||
delay(16)
|
delay(16)
|
||||||
runCatching {
|
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)) {
|
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
|
||||||
Column(
|
Column(
|
||||||
@@ -149,19 +168,20 @@ fun MyAlertsPage(
|
|||||||
AlertsEmptyState(enabled = preferences.enabled)
|
AlertsEmptyState(enabled = preferences.enabled)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
contentPadding = PaddingValues(vertical = 6.dp),
|
contentPadding = PaddingValues(vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
items(notifications, key = UserNotification::id) { notification ->
|
itemsIndexed(notifications, key = { _, notification -> notification.id }) {
|
||||||
|
index, notification ->
|
||||||
AlertRow(
|
AlertRow(
|
||||||
notification = notification,
|
notification = notification,
|
||||||
modifier = if (notification.id == notifications.first().id) {
|
modifier = Modifier.focusRequester(rowFocusRequesters[index]),
|
||||||
Modifier.focusRequester(listFocusRequester)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
onFocused = { if (notification.unread) onRead(notification) },
|
onFocused = { if (notification.unread) onRead(notification) },
|
||||||
onClick = { onDismiss(notification) },
|
onClick = {
|
||||||
|
pendingFocusIndex = index
|
||||||
|
onDismiss(notification)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (notification.id != notifications.last().id) {
|
if (notification.id != notifications.last().id) {
|
||||||
Box(
|
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
|
@Composable
|
||||||
private fun AlertsHeader(total: Int, unread: Int) {
|
private fun AlertsHeader(total: Int, unread: Int) {
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.view.Gravity
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.HorizontalScrollView
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
@@ -19,6 +20,25 @@ data class CastMember(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val role: String? = null,
|
val role: String? = null,
|
||||||
val imageUrl: 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 title: String = "",
|
||||||
val members: List<CastMember> = emptyList(),
|
val members: List<CastMember> = emptyList(),
|
||||||
val loaded: Boolean = false,
|
val loaded: Boolean = false,
|
||||||
|
val selectedPerson: CastPersonPanel? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,14 +68,20 @@ fun bindCastPanel(
|
|||||||
overlay: View,
|
overlay: View,
|
||||||
state: CastPanelState,
|
state: CastPanelState,
|
||||||
loadImage: (ImageView, String) -> Unit = { _, _ -> },
|
loadImage: (ImageView, String) -> Unit = { _, _ -> },
|
||||||
|
onMemberClick: (CastMember) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = overlay.context
|
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 {
|
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
|
||||||
text = state.title
|
text = selected?.name ?: state.title
|
||||||
isVisible = state.title.isNotBlank()
|
isVisible = !text.isNullOrBlank()
|
||||||
}
|
}
|
||||||
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
|
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
|
||||||
text = when {
|
text = when {
|
||||||
|
selected != null && !selected.loaded -> context.getString(R.string.player_cast_profile_loading)
|
||||||
!state.loaded -> context.getString(R.string.player_cast_loading)
|
!state.loaded -> context.getString(R.string.player_cast_loading)
|
||||||
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
|
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
|
||||||
else -> ""
|
else -> ""
|
||||||
@@ -63,14 +90,26 @@ fun bindCastPanel(
|
|||||||
}
|
}
|
||||||
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
|
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
|
||||||
people.removeAllViews()
|
people.removeAllViews()
|
||||||
state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) }
|
state.members.forEach { member ->
|
||||||
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty()
|
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(
|
private fun castCard(
|
||||||
context: Context,
|
context: Context,
|
||||||
member: CastMember,
|
member: CastMember,
|
||||||
loadImage: (ImageView, String) -> Unit,
|
loadImage: (ImageView, String) -> Unit,
|
||||||
|
onClick: (CastMember) -> Unit,
|
||||||
): View {
|
): View {
|
||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
fun dp(value: Int) = (value * density).toInt()
|
fun dp(value: Int) = (value * density).toInt()
|
||||||
@@ -79,10 +118,7 @@ private fun castCard(
|
|||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
isFocusable = true
|
isFocusable = true
|
||||||
isClickable = true
|
isClickable = true
|
||||||
// Nothing happens on a press. The panel is a reference, not a destination — there
|
setOnClickListener { onClick(member) }
|
||||||
// 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 { }
|
|
||||||
clipChildren = false
|
clipChildren = false
|
||||||
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
marginEnd = dp(18)
|
marginEnd = dp(18)
|
||||||
@@ -96,17 +132,30 @@ private fun castCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
addView(castPortrait(context, member, loadImage, ::dp))
|
addView(castPortrait(context, member, loadImage, ::dp))
|
||||||
addView(
|
addView(LinearLayout(context).apply {
|
||||||
TextView(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
|
text = member.name
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
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 ->
|
member.role?.takeIf(String::isNotBlank)?.let { role ->
|
||||||
addView(
|
addView(
|
||||||
TextView(context).apply {
|
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.
|
* The portrait, with the person's initials underneath it.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -87,3 +87,11 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? =
|
|||||||
2 -> 3_000L
|
2 -> 3_000L
|
||||||
else -> null
|
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.resolveCast
|
||||||
import com.ponzischeme89.memby.data.selectSubtitleId
|
import com.ponzischeme89.memby.data.selectSubtitleId
|
||||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
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.GatewayPrerollEntry
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
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.randomWelcomeQuote
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -134,6 +138,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var streamStatusView: TextView? = null
|
private var streamStatusView: TextView? = null
|
||||||
private var retryJob: Job? = null
|
private var retryJob: Job? = null
|
||||||
private var stablePlaybackJob: Job? = null
|
private var stablePlaybackJob: Job? = null
|
||||||
|
private var prolongedRebufferJob: Job? = null
|
||||||
|
private var prolongedRebufferRecoveryAttempted = false
|
||||||
private var automaticRetryAttempt = 0
|
private var automaticRetryAttempt = 0
|
||||||
private var renderedFirstFrame = false
|
private var renderedFirstFrame = false
|
||||||
private var requestStartedAtMs = 0L
|
private var requestStartedAtMs = 0L
|
||||||
@@ -160,6 +166,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var prerollEpisodeCode = ""
|
private var prerollEpisodeCode = ""
|
||||||
private var prerollRuntimeMs = 0L
|
private var prerollRuntimeMs = 0L
|
||||||
private var prerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
private var prerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
||||||
|
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
||||||
private var pausePosterUrl: String? = null
|
private var pausePosterUrl: String? = null
|
||||||
private var pauseOverlay: View? = null
|
private var pauseOverlay: View? = null
|
||||||
private var nowPlayingGroup: View? = null
|
private var nowPlayingGroup: View? = null
|
||||||
@@ -222,7 +229,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var castJob: Job? = null
|
private var castJob: Job? = null
|
||||||
private var castPeople: List<EmbyPerson> = emptyList()
|
private var castPeople: List<EmbyPerson> = emptyList()
|
||||||
private var castLoaded = false
|
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 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 prerollTimerJob: Job? = null
|
||||||
private var prerollScheduleJob: Job? = null
|
private var prerollScheduleJob: Job? = null
|
||||||
private var prerollActive = true
|
private var prerollActive = true
|
||||||
@@ -391,10 +404,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
|
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
|
||||||
initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
|
initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
|
||||||
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
|
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
|
||||||
prerollDurationMs = intent.getLongExtra(
|
configuredPrerollDurationMs = intent.getLongExtra(
|
||||||
EXTRA_PREROLL_DURATION_MS,
|
EXTRA_PREROLL_DURATION_MS,
|
||||||
DEFAULT_PREROLL_DURATION_MS,
|
DEFAULT_PREROLL_DURATION_MS,
|
||||||
).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS)
|
).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS)
|
||||||
|
prerollDurationMs = configuredPrerollDurationMs
|
||||||
val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled)
|
val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled)
|
||||||
val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
|
val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
|
||||||
availableSubtitles = subtitles
|
availableSubtitles = subtitles
|
||||||
@@ -489,8 +503,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// with it, so the ceiling falls before the overlay goes up.
|
// with it, so the ceiling falls before the overlay goes up.
|
||||||
stepDownCreditsSpeed()
|
stepDownCreditsSpeed()
|
||||||
if (!prerollActive && !seekBuffering) showPlaybackLoading()
|
if (!prerollActive && !seekBuffering) showPlaybackLoading()
|
||||||
|
scheduleProlongedRebufferRecovery()
|
||||||
}
|
}
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
|
prolongedRebufferJob?.cancel()
|
||||||
|
prolongedRebufferJob = null
|
||||||
trace.mark(PlaybackTrace.READY)
|
trace.mark(PlaybackTrace.READY)
|
||||||
endSeekBuffering()
|
endSeekBuffering()
|
||||||
hidePlaybackError()
|
hidePlaybackError()
|
||||||
@@ -551,10 +568,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
renderedFirstFrame = true
|
renderedFirstFrame = true
|
||||||
endSeekBuffering()
|
endSeekBuffering()
|
||||||
hidePlaybackLoading()
|
hidePlaybackLoading()
|
||||||
if (!playbackStarted) {
|
if (!playbackStarted && !prerollActive) {
|
||||||
startPlaybackSession(playback)
|
startPlaybackSession(playback)
|
||||||
}
|
}
|
||||||
if (prerollActive) {
|
if (prerollActive && localPrerollPlayer == null) {
|
||||||
startPrerollCountdown()
|
startPrerollCountdown()
|
||||||
}
|
}
|
||||||
endFirstFrameTrace()
|
endFirstFrameTrace()
|
||||||
@@ -724,24 +741,127 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
useController = false
|
useController = false
|
||||||
hideController()
|
hideController()
|
||||||
}
|
}
|
||||||
enterPrerollVideoFrame()
|
if (!attachLocalPreroll()) enterPrerollVideoFrame()
|
||||||
bindPrerollNow()
|
bindPrerollNow()
|
||||||
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
|
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
|
||||||
prerollScheduleJob = lifecycleScope.launch {
|
prerollScheduleJob = lifecycleScope.launch {
|
||||||
val schedule = ServiceLocator.repository.prerollSchedule()
|
val schedule = ServiceLocator.repository.prerollSchedule()
|
||||||
if (prerollActive) bindPrerollSchedule(schedule, loading = false)
|
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(
|
findViewById<PrerollCountdownView>(R.id.player_preroll_countdown).setCountdown(
|
||||||
seconds = ceil(prerollDurationMs / 1_000.0).toInt(),
|
seconds = seconds,
|
||||||
progress = 1f,
|
progress = 1f,
|
||||||
description = resources.getQuantityString(
|
description = resources.getQuantityString(
|
||||||
R.plurals.player_preroll_countdown,
|
R.plurals.player_preroll_countdown,
|
||||||
ceil(prerollDurationMs / 1_000.0).toInt(),
|
seconds,
|
||||||
ceil(prerollDurationMs / 1_000.0).toInt(),
|
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. */
|
/** Resume is a continuation, not a new programme start, so it bypasses the pre-roll. */
|
||||||
private fun startWithoutPreroll() {
|
private fun startWithoutPreroll() {
|
||||||
prerollView = findViewById<View>(R.id.player_preroll).also {
|
prerollView = findViewById<View>(R.id.player_preroll).also {
|
||||||
@@ -781,7 +901,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val nowMs = SystemClock.elapsedRealtime()
|
val nowMs = SystemClock.elapsedRealtime()
|
||||||
if (prerollCountdownAdvances(
|
if (prerollCountdownAdvances(
|
||||||
lifecycleStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
|
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)
|
watchedMs = (watchedMs + nowMs - lastTickMs).coerceAtMost(durationMs)
|
||||||
@@ -789,13 +910,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
lastTickMs = nowMs
|
lastTickMs = nowMs
|
||||||
}
|
}
|
||||||
if (!prerollActive) return@launch
|
if (!prerollActive) return@launch
|
||||||
prerollMinimumElapsed = true
|
completePrerollCountdown()
|
||||||
countdown.setCountdown(
|
|
||||||
seconds = 0,
|
|
||||||
progress = 0f,
|
|
||||||
description = getString(R.string.player_preroll_starting),
|
|
||||||
)
|
|
||||||
beginContentWhenReady()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +1008,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun finishPrerollHandOff(playback: Player) {
|
private fun finishPrerollHandOff(playback: Player) {
|
||||||
|
disposeLocalPreroll(reuse = true)
|
||||||
restoreFullscreenPlayer()
|
restoreFullscreenPlayer()
|
||||||
prerollView?.visibility = View.GONE
|
prerollView?.visibility = View.GONE
|
||||||
playerView?.apply {
|
playerView?.apply {
|
||||||
@@ -1077,6 +1193,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
playerView?.useController = true
|
playerView?.useController = true
|
||||||
}
|
}
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
|
prolongedRebufferJob?.cancel()
|
||||||
|
prolongedRebufferJob = null
|
||||||
stablePlaybackJob?.cancel()
|
stablePlaybackJob?.cancel()
|
||||||
// The skip is over however it ended; nothing after this may be withheld on its
|
// 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.
|
// 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) {
|
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
|
prolongedRebufferJob?.cancel()
|
||||||
|
prolongedRebufferJob = null
|
||||||
hidePlaybackError()
|
hidePlaybackError()
|
||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
// A launch whose resolution failed has no media item to re-prepare and no playhead
|
// 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) {
|
private fun showPlaybackError(failure: PlaybackFailure) {
|
||||||
hidePlaybackLoading()
|
hidePlaybackLoading()
|
||||||
playerView?.hideController()
|
playerView?.hideController()
|
||||||
@@ -2664,6 +2817,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
|
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
|
||||||
renderedFirstFrame = false
|
renderedFirstFrame = false
|
||||||
automaticRetryAttempt = 0
|
automaticRetryAttempt = 0
|
||||||
|
prolongedRebufferJob?.cancel()
|
||||||
|
prolongedRebufferJob = null
|
||||||
|
prolongedRebufferRecoveryAttempted = false
|
||||||
resetTimeRemainingCue()
|
resetTimeRemainingCue()
|
||||||
// A skip aimed at the outgoing episode must not land in the incoming one.
|
// A skip aimed at the outgoing episode must not land in the incoming one.
|
||||||
resetSeekControls()
|
resetSeekControls()
|
||||||
@@ -2746,6 +2902,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// activity before our UP handler gets a chance to hide the active surface.
|
// activity before our UP handler gets a chance to hide the active surface.
|
||||||
if (event.action == KeyEvent.ACTION_UP) {
|
if (event.action == KeyEvent.ACTION_UP) {
|
||||||
when {
|
when {
|
||||||
|
castOverlay?.isVisible == true && selectedCastPerson != null ->
|
||||||
|
showCastList()
|
||||||
castOverlay?.isVisible == true -> hideCastOverlay()
|
castOverlay?.isVisible == true -> hideCastOverlay()
|
||||||
// The drop-up's download half is a level of its own, so Back leaves it
|
// 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.
|
// before it leaves the menu — one press per level, never two at once.
|
||||||
@@ -2964,8 +3122,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun loadCast() {
|
private fun loadCast() {
|
||||||
castJob?.cancel()
|
castJob?.cancel()
|
||||||
|
castPersonJob?.cancel()
|
||||||
castLoaded = false
|
castLoaded = false
|
||||||
castPeople = emptyList()
|
castPeople = emptyList()
|
||||||
|
castProfiles = emptyMap()
|
||||||
|
selectedCastPerson = null
|
||||||
val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
|
val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
|
||||||
castLoaded = true
|
castLoaded = true
|
||||||
bindCastOverlay()
|
bindCastOverlay()
|
||||||
@@ -2981,6 +3142,18 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
castLoaded = true
|
castLoaded = true
|
||||||
bindCastOverlay()
|
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() {
|
private fun hideCastOverlay() {
|
||||||
|
castPersonJob?.cancel()
|
||||||
|
selectedCastPerson = null
|
||||||
castOverlay?.visibility = View.GONE
|
castOverlay?.visibility = View.GONE
|
||||||
playerView?.showController()
|
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() {
|
private fun bindCastOverlay() {
|
||||||
val overlay = castOverlay ?: return
|
val overlay = castOverlay ?: return
|
||||||
bindCastPanel(
|
bindCastPanel(
|
||||||
@@ -3003,23 +3229,36 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
state = CastPanelState(
|
state = CastPanelState(
|
||||||
title = playbackTitle,
|
title = playbackTitle,
|
||||||
members = castPeople.map { person ->
|
members = castPeople.map { person ->
|
||||||
|
val profile = castProfiles[person.id]
|
||||||
CastMember(
|
CastMember(
|
||||||
name = person.name,
|
name = person.name,
|
||||||
role = person.role,
|
role = person.role,
|
||||||
imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
|
imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
|
||||||
|
id = person.id,
|
||||||
|
deathDate = profile?.endDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
loaded = castLoaded,
|
loaded = castLoaded,
|
||||||
|
selectedPerson = selectedCastPerson,
|
||||||
),
|
),
|
||||||
// Coil is handed in rather than reached for inside the panel, which is what lets
|
// 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.
|
// the screenshot test render the same cards without a network.
|
||||||
loadImage = { view, url -> view.load(url) },
|
loadImage = { view, url -> view.load(url) },
|
||||||
|
onMemberClick = ::showCastPerson,
|
||||||
)
|
)
|
||||||
if (overlay.isVisible) {
|
if (overlay.isVisible) {
|
||||||
|
if (selectedCastPerson == null) {
|
||||||
overlay.findViewById<LinearLayout>(R.id.player_cast_people)
|
overlay.findViewById<LinearLayout>(R.id.player_cast_people)
|
||||||
.getChildAt(0)
|
.getChildAt(0)
|
||||||
?.requestFocus()
|
?.requestFocus()
|
||||||
?: overlay.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() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
if (prerollActive) {
|
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()
|
beginContentWhenReady()
|
||||||
if (stoppedInBackground && playbackStarted) {
|
if (stoppedInBackground && playbackStarted) {
|
||||||
@@ -3507,6 +3748,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
localPrerollPlayer?.pause()
|
||||||
player?.pause()
|
player?.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3518,6 +3760,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
pendingResolveJob?.cancel()
|
pendingResolveJob?.cancel()
|
||||||
prerollTimerJob?.cancel()
|
prerollTimerJob?.cancel()
|
||||||
prerollScheduleJob?.cancel()
|
prerollScheduleJob?.cancel()
|
||||||
|
disposeLocalPreroll(reuse = true)
|
||||||
playbackStartCueJob?.cancel()
|
playbackStartCueJob?.cancel()
|
||||||
timeRemainingHideJob?.cancel()
|
timeRemainingHideJob?.cancel()
|
||||||
seekCommitJob?.cancel()
|
seekCommitJob?.cancel()
|
||||||
@@ -3527,12 +3770,14 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
seasonFinaleHideJob?.cancel()
|
seasonFinaleHideJob?.cancel()
|
||||||
prerollView?.findViewById<View>(R.id.player_preroll_video_host)?.animate()?.cancel()
|
prerollView?.findViewById<View>(R.id.player_preroll_video_host)?.animate()?.cancel()
|
||||||
castJob?.cancel()
|
castJob?.cancel()
|
||||||
|
castPersonJob?.cancel()
|
||||||
subtitleSearchJob?.cancel()
|
subtitleSearchJob?.cancel()
|
||||||
nextUpJob?.cancel()
|
nextUpJob?.cancel()
|
||||||
creditsSpeedJob?.cancel()
|
creditsSpeedJob?.cancel()
|
||||||
creditsView?.animate()?.cancel()
|
creditsView?.animate()?.cancel()
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
stablePlaybackJob?.cancel()
|
stablePlaybackJob?.cancel()
|
||||||
|
prolongedRebufferJob?.cancel()
|
||||||
playbackIdentityHideJob?.cancel()
|
playbackIdentityHideJob?.cancel()
|
||||||
playbackIdentityView?.animate()?.cancel()
|
playbackIdentityView?.animate()?.cancel()
|
||||||
loadingAnimator?.cancel()
|
loadingAnimator?.cancel()
|
||||||
@@ -3891,6 +4136,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val NO_TRACE = -1
|
private const val NO_TRACE = -1
|
||||||
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
|
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
|
||||||
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
|
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 const val PLAYBACK_LOG_TAG = "MembyPlayback"
|
||||||
|
|
||||||
private fun playbackStateName(state: Int): String =
|
private fun playbackStateName(state: Int): String =
|
||||||
@@ -3949,10 +4195,15 @@ internal fun prerollCanHandOff(
|
|||||||
lifecycleStarted: Boolean,
|
lifecycleStarted: Boolean,
|
||||||
): Boolean = active && minimumElapsed && lifecycleStarted
|
): 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 =
|
internal fun prerollCountdownAdvances(lifecycleStarted: Boolean, playbackReady: Boolean): Boolean =
|
||||||
lifecycleStarted && playbackReady
|
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. */
|
/** A positive position means the viewer is continuing something already started. */
|
||||||
internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): Boolean =
|
internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): Boolean =
|
||||||
enabled && resumePositionMs <= 0L
|
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:paddingStart="48dp"
|
||||||
android:paddingTop="20dp"
|
android:paddingTop="20dp"
|
||||||
android:paddingEnd="48dp"
|
android:paddingEnd="48dp"
|
||||||
android:paddingBottom="34dp">
|
android:paddingBottom="54dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/player_cast_eyebrow"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:letterSpacing="0.14"
|
android:letterSpacing="0.14"
|
||||||
@@ -78,7 +79,16 @@
|
|||||||
android:orientation="horizontal" />
|
android:orientation="horizontal" />
|
||||||
</HorizontalScrollView>
|
</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
|
<TextView
|
||||||
|
android:id="@+id/player_cast_back_hint"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
|||||||
Binary file not shown.
@@ -27,6 +27,13 @@
|
|||||||
<string name="player_cast_eyebrow">CAST</string>
|
<string name="player_cast_eyebrow">CAST</string>
|
||||||
<string name="player_cast_loading">Loading 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_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_subtitle_track">SUBTITLES</string>
|
||||||
<string name="player_text_size">TEXT SIZE</string>
|
<string name="player_text_size">TEXT SIZE</string>
|
||||||
<string name="player_subtitle_download">GET SUBTITLES</string>
|
<string name="player_subtitle_download">GET SUBTITLES</string>
|
||||||
|
|||||||
@@ -97,5 +97,25 @@ class CastMetadataTest {
|
|||||||
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
|
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")
|
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.DevicePlaybackCapabilities
|
||||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
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.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -45,4 +48,14 @@ class DevicePlaybackCapabilitiesTest {
|
|||||||
assertTrue("video_h264_decode" in tokens)
|
assertTrue("video_h264_decode" in tokens)
|
||||||
assertTrue("video_hevc_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
|
import org.junit.Test
|
||||||
|
|
||||||
class AlertsFormatTest {
|
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
|
@Test
|
||||||
fun `no alerts wears no badge`() {
|
fun `no alerts wears no badge`() {
|
||||||
assertNull(alertBadgeLabel(0))
|
assertNull(alertBadgeLabel(0))
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ class CastPanelScreenshotTest {
|
|||||||
title = "The Lives of Others",
|
title = "The Lives of Others",
|
||||||
loaded = true,
|
loaded = true,
|
||||||
members = listOf(
|
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("Martina Gedeck", "Christa-Maria Sieland"),
|
||||||
CastMember("Sebastian Koch", "Georg Dreyman"),
|
CastMember("Sebastian Koch", "Georg Dreyman"),
|
||||||
CastMember("Ulrich Tukur", "Oberstleutnant Anton Grubitz"),
|
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. */
|
/** Still fetching. The panel opens instantly and says so rather than showing nothing. */
|
||||||
@Test
|
@Test
|
||||||
fun `still loading`() {
|
fun `still loading`() {
|
||||||
@@ -118,6 +149,10 @@ class CastPanelScreenshotTest {
|
|||||||
assertEquals("C", castInitials("Cher"))
|
assertEquals("C", castInitials("Cher"))
|
||||||
assertEquals("AA", castInitials(" amy adams "))
|
assertEquals("AA", castInitials(" amy adams "))
|
||||||
assertEquals("", castInitials(" "))
|
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) {
|
private fun capture(name: String, state: CastPanelState, focused: Int? = null) {
|
||||||
|
|||||||
@@ -53,4 +53,13 @@ class PlaybackRecoveryTest {
|
|||||||
assertEquals(3_000L, automaticRetryDelayMs(2))
|
assertEquals(3_000L, automaticRetryDelayMs(2))
|
||||||
assertNull(automaticRetryDelayMs(3))
|
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
|
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.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -40,4 +42,11 @@ class PrerollSequenceTest {
|
|||||||
assertFalse(prerollCountdownAdvances(lifecycleStarted = false, playbackReady = true))
|
assertFalse(prerollCountdownAdvances(lifecycleStarted = false, playbackReady = true))
|
||||||
assertFalse(prerollCountdownAdvances(lifecycleStarted = true, playbackReady = false))
|
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
|
dismisses the confirmation, and Left/Right is explicitly constrained between the two
|
||||||
choices. The setting defaults off and is stored per television.
|
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
|
### NAV-002 — Recommendation onboarding has no deterministic entry focus or Back route
|
||||||
|
|
||||||
- Severity: High
|
- Severity: High
|
||||||
- Surface: First-time recommendation onboarding
|
- Surface: First-time recommendation onboarding
|
||||||
- Reproduction: Sign in with a profile that has been prompted for taste 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
|
- Previous behaviour: The screen created focusable title/person cards and footer buttons but did not
|
||||||
request initial focus. It also has no `BackHandler`. Focus therefore depends on Compose's
|
request initial focus. It also had no `BackHandler`. Focus therefore depended on Compose's
|
||||||
geometric fallback, while Back can leave the activity instead of moving to a defined
|
geometric fallback, while Back could leave the activity instead of moving to a defined
|
||||||
previous step or presenting a skip choice.
|
previous step or presenting a skip choice.
|
||||||
- Recommended fix: Give the first card (or **Next** when the stage is empty) an explicit
|
- Resolution: The first choice now receives focus explicitly; an empty stage focuses its
|
||||||
entry requester. Make Back move to the previous stage; on the first stage, focus a clear
|
primary footer action. Back moves to the previous stage, while Back on the first stage
|
||||||
**Skip for now** confirmation instead of closing the app.
|
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
|
### 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
|
- Surface: Manage users / profile chooser
|
||||||
- Reproduction: Save enough profiles that the fixed 154 dp tiles plus 24 dp gaps exceed the
|
- Reproduction: Save enough profiles that the fixed 154 dp tiles plus 24 dp gaps exceed the
|
||||||
viewport width.
|
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.
|
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
|
- Resolution: Profiles now use a padded `LazyRow` with stable profile keys, so focus brings
|
||||||
horizontal state. Keep **Add another user** as the final item.
|
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
|
### 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
|
- Surface: Manage users / profile chooser
|
||||||
- Reproduction: Move around a profile tile and its small × removal control using only the
|
- Reproduction: Move around a profile tile and its small × removal control using only the
|
||||||
D-pad.
|
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
|
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
|
select a different profile or skip the removal target. The 28 dp target is also difficult
|
||||||
to identify at TV distance.
|
to identify at TV distance.
|
||||||
- Recommended fix: Put profile actions in a deterministic vertical group: OK opens the
|
- Resolution: Every profile is now a deterministic two-action column: the profile tile and
|
||||||
profile and a labelled **Remove user** action sits below it, with explicit Up/Down and
|
a full-width, labelled **Remove user** action. Explicit Up/Down and Left/Right mappings
|
||||||
Left/Right neighbours. Retain the existing safe Cancel-first confirmation.
|
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
|
### NAV-005 — Dismissing an alert can leave focus without a stable successor
|
||||||
|
|
||||||
- Severity: Medium
|
- Severity: Medium
|
||||||
- Surface: My Alerts
|
- Surface: My Alerts
|
||||||
- Reproduction: Focus an alert in the middle of the list and press OK to dismiss it.
|
- 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
|
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
|
previous alert after an individual removal, so focus restoration was left to framework
|
||||||
behaviour and can appear to vanish on some Compose/TV combinations.
|
behaviour and could appear to vanish on some Compose/TV combinations.
|
||||||
- Recommended fix: Track the focused alert id/index and, after removal, request the item now
|
- Resolution: The page records the dismissed row index, scrolls the remaining list to the
|
||||||
occupying that index (or the previous item). When the list becomes empty, move focus to
|
row now occupying that position, and requests it after composition. Removing the final
|
||||||
the alert controls explicitly.
|
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
|
### NAV-006 — Onboarding stage changes do not preserve a clear focus destination
|
||||||
|
|
||||||
- Severity: Medium
|
- Severity: Medium
|
||||||
- Surface: Recommendation onboarding
|
- Surface: Recommendation onboarding
|
||||||
- Reproduction: Focus **Next**, advance a stage, then continue with the D-pad.
|
- 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
|
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.
|
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
|
- Resolution: Every stage change now places focus on the first choice, making the new
|
||||||
last focused choice per stage. Give the footer buttons explicit Up targets and the card
|
question immediately actionable. Choice cards explicitly move Down to the primary footer
|
||||||
row an explicit Down target.
|
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
|
## 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("PUT /v1/preferences", s.authed(s.handlePreferences))
|
||||||
|
|
||||||
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
|
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}/ratings", s.authed(s.handleMovieRatings))
|
||||||
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
|
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
|
||||||
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
|
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},
|
"UserId": {cred.UserID},
|
||||||
"IsPlayback": {"true"},
|
"IsPlayback": {"true"},
|
||||||
}
|
}
|
||||||
|
profile := androidTVDeviceProfile(capabilities)
|
||||||
|
if forceTranscode {
|
||||||
|
forceH264TranscodeProfile(profile)
|
||||||
|
}
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
|
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
|
||||||
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
|
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
|
||||||
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
|
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
|
||||||
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
|
"AllowVideoStreamCopy": !forceTranscode, "AllowAudioStreamCopy": true,
|
||||||
"DeviceProfile": androidTVDeviceProfile(capabilities),
|
"DeviceProfile": profile,
|
||||||
})
|
})
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -314,10 +318,6 @@ func (c *Client) PlaybackInfo(
|
|||||||
if currentPlaySessionID != "" {
|
if currentPlaySessionID != "" {
|
||||||
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
|
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
|
||||||
}
|
}
|
||||||
if forceTranscode {
|
|
||||||
profile := requestBody["DeviceProfile"].(map[string]any)
|
|
||||||
profile["DirectPlayProfiles"] = []map[string]string{}
|
|
||||||
}
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
body, err = json.Marshal(requestBody)
|
body, err = json.Marshal(requestBody)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,3 +32,23 @@ func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
|
|||||||
t.Fatalf("codec profiles = %#v", codecProfiles)
|
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 {
|
func androidTVSubtitleProfiles() []map[string]string {
|
||||||
return []map[string]string{
|
return []map[string]string{
|
||||||
{"Format": "srt", "Method": "External"},
|
{"Format": "srt", "Method": "External"},
|
||||||
|
|||||||
Reference in New Issue
Block a user