0.2.56 - Reliable trailer playback
This commit is contained in:
@@ -277,6 +277,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
private val trailerCache =
|
||||
LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true)
|
||||
private val trailerInFlight = mutableMapOf<String, Deferred<CachedTrailer?>>()
|
||||
private val trailerAvailabilityCache =
|
||||
LinkedHashMap<String, Boolean>(TRAILER_CACHE_SIZE, 0.75f, true)
|
||||
private val trailerAvailabilityInFlight = mutableMapOf<String, Deferred<Boolean?>>()
|
||||
|
||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||
|
||||
@@ -1409,6 +1412,88 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return inFlight.await()?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the detail page should offer Trailer. In gateway mode this includes every
|
||||
* registered provider but does not resolve a stream or create a playback session.
|
||||
*/
|
||||
suspend fun hasTrailer(itemId: String): Boolean {
|
||||
if (itemId.isBlank()) return false
|
||||
val request = trailerMutex.withLock {
|
||||
trailerAvailabilityCache[itemId]?.let { return it }
|
||||
trailerAvailabilityInFlight[itemId] ?: scope.async(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
val loaded = if (ServerConfig.isGateway) {
|
||||
runCatching { requireGateway().trailers(itemId).available }
|
||||
.recoverCatching { error ->
|
||||
if (error is HttpException && error.code() == 404) {
|
||||
getLocalTrailer(itemId) != null
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
} else {
|
||||
getLocalTrailer(itemId) != null
|
||||
}
|
||||
if (loaded != null) trailerMutex.withLock {
|
||||
trailerAvailabilityCache[itemId] = loaded
|
||||
while (trailerAvailabilityCache.size > TRAILER_CACHE_SIZE) {
|
||||
trailerAvailabilityCache.entries.iterator().run { next(); remove() }
|
||||
}
|
||||
}
|
||||
loaded
|
||||
} finally {
|
||||
trailerMutex.withLock { trailerAvailabilityInFlight.remove(itemId) }
|
||||
}
|
||||
}.also {
|
||||
trailerAvailabilityInFlight[itemId] = it
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
return request.await() ?: false
|
||||
}
|
||||
|
||||
/** Resolves one provider candidate after the player is already open. */
|
||||
suspend fun resolveTrailer(
|
||||
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
|
||||
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
|
||||
if (ServerConfig.isGateway) {
|
||||
return runCatching {
|
||||
requireGateway().resolveTrailer(
|
||||
request.subjectId,
|
||||
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(
|
||||
request.excludedCandidateIds,
|
||||
),
|
||||
)
|
||||
}.recoverCatching { error ->
|
||||
if (error is HttpException && error.code() == 404 && request.excludedCandidateIds.isEmpty()) {
|
||||
resolveLegacyLocalTrailer(request)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}.getOrThrow()
|
||||
}
|
||||
return resolveLegacyLocalTrailer(request)
|
||||
}
|
||||
|
||||
private suspend fun resolveLegacyLocalTrailer(
|
||||
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
|
||||
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
|
||||
val local = getLocalTrailer(request.subjectId)
|
||||
?: throw NoSuchElementException("No local trailer is available")
|
||||
val playable = resolvePlayableForLaunch(local)
|
||||
return com.ponzischeme89.memby.data.model.GatewayTrailerPlayback(
|
||||
candidateId = "local-${local.id}",
|
||||
provider = "local",
|
||||
url = playable.url,
|
||||
title = request.title + " trailer",
|
||||
itemId = playable.itemId,
|
||||
mediaSourceId = playable.mediaSourceId,
|
||||
playSessionId = playable.playSessionId,
|
||||
playMethod = playable.playMethod,
|
||||
)
|
||||
}
|
||||
|
||||
private fun newTrailerRequest(itemId: String): Deferred<CachedTrailer?> {
|
||||
val request = scope.async(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
@@ -1893,6 +1978,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
// but the request in flight carries the outgoing session, and the next profile
|
||||
// may be signed into a different server entirely.
|
||||
trailerCache.clear()
|
||||
trailerAvailabilityCache.clear()
|
||||
trailerAvailabilityInFlight.values.forEach { it.cancel() }
|
||||
trailerAvailabilityInFlight.clear()
|
||||
trailerInFlight.values.forEach { it.cancel() }
|
||||
trailerInFlight.clear()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
|
||||
/** Records a rejected provider candidate without changing the trailer's screen identity. */
|
||||
internal fun afterTrailerCandidate(
|
||||
request: TrailerPlaybackRequest,
|
||||
candidateId: String,
|
||||
): TrailerPlaybackRequest = request.copy(
|
||||
excludedCandidateIds = (request.excludedCandidateIds + candidateId.trim())
|
||||
.filter(String::isNotBlank)
|
||||
.distinct(),
|
||||
)
|
||||
|
||||
/** Trailer failures always advance the provider chain instead of opening the generic error pane. */
|
||||
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer
|
||||
@@ -40,6 +40,40 @@ data class GatewayDevices(
|
||||
val devices: List<GatewayDevice> = emptyList(),
|
||||
)
|
||||
|
||||
/** Cheap detail-page answer; actual stream resolution begins only after Trailer is pressed. */
|
||||
@Serializable
|
||||
data class GatewayTrailerAvailability(
|
||||
val available: Boolean = false,
|
||||
val providers: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayTrailerResolveRequest(
|
||||
val excludedCandidateIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** One native stream selected by the gateway's ordered trailer-provider chain. */
|
||||
@Serializable
|
||||
data class GatewayTrailerPlayback(
|
||||
val candidateId: String = "",
|
||||
val provider: String = "",
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val itemId: String = "",
|
||||
val mediaSourceId: String = "",
|
||||
val playSessionId: String = "",
|
||||
val playMethod: String = "DirectPlay",
|
||||
)
|
||||
|
||||
/** Persistable player request so provider fallback survives Activity recreation. */
|
||||
@Serializable
|
||||
data class TrailerPlaybackRequest(
|
||||
val subjectId: String,
|
||||
val title: String,
|
||||
val posterUrl: String? = null,
|
||||
val excludedCandidateIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
|
||||
@@ -289,6 +289,19 @@ interface GatewayApi {
|
||||
@GET("v1/items/{id}/trailer")
|
||||
suspend fun trailer(@Path("id") itemId: String): BaseItem
|
||||
|
||||
/** Whether local, Apple or YouTube trailer metadata exists for this item. */
|
||||
@GET("v1/items/{id}/trailers")
|
||||
suspend fun trailers(
|
||||
@Path("id") itemId: String,
|
||||
): com.ponzischeme89.memby.data.model.GatewayTrailerAvailability
|
||||
|
||||
/** Resolve the best candidate not already rejected by this player session. */
|
||||
@POST("v1/items/{id}/trailers/resolve")
|
||||
suspend fun resolveTrailer(
|
||||
@Path("id") itemId: String,
|
||||
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest,
|
||||
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
|
||||
|
||||
@POST("v1/items/{id}/favorite")
|
||||
suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActions
|
||||
import com.ponzischeme89.memby.ui.screensaver.ScreensaverContent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
@@ -60,7 +62,7 @@ class MembyDreamService : DreamService() {
|
||||
setContent {
|
||||
MembyTheme {
|
||||
ScreensaverContent(
|
||||
onPlay = { url, title -> launchPlayback(url, title) },
|
||||
onPlay = ::launchTrailer,
|
||||
onExit = { finish() },
|
||||
actions = actions,
|
||||
)
|
||||
@@ -113,8 +115,11 @@ class MembyDreamService : DreamService() {
|
||||
* the dream" race some TV builds exhibit. Uses the application context because
|
||||
* this service is being torn down.
|
||||
*/
|
||||
private fun launchPlayback(url: String, title: String) {
|
||||
val intent = PlayerActivity.intent(applicationContext, url, title)
|
||||
private fun launchTrailer(item: BaseItem) {
|
||||
val intent = PlayerActivity.trailerIntent(
|
||||
applicationContext,
|
||||
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
|
||||
)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
finish()
|
||||
mainHandler.postDelayed({
|
||||
|
||||
@@ -448,7 +448,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
if (!seriesId.isNullOrBlank()) {
|
||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
||||
}
|
||||
launch { runCatching { repository.getLocalTrailer(item.id) } }
|
||||
launch { runCatching { repository.hasTrailer(item.id) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ import com.ponzischeme89.memby.data.model.MyShow
|
||||
import com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPerson
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
@@ -2042,7 +2043,10 @@ private fun HomeScreen(
|
||||
var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
|
||||
val playbackLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
) {
|
||||
) { result ->
|
||||
if (PlayerActivity.trailerUnavailable(result.resultCode, result.data)) {
|
||||
Toast.makeText(context, "No playable trailer is available", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "stop", screen = "player",
|
||||
feature = "playback", target = selectedDestination.name.lowercase(),
|
||||
@@ -2068,9 +2072,12 @@ private fun HomeScreen(
|
||||
if (it is kotlinx.coroutines.CancellationException) throw it
|
||||
notificationsError = friendlyEmbyError(it)
|
||||
}
|
||||
myShowsLoading = false
|
||||
notificationsLoading = false
|
||||
myShowsLoading = false
|
||||
notificationsLoading = false
|
||||
kotlinx.coroutines.delay(32L)
|
||||
// Trailer playback leaves its detail page composed. Let Compose restore the
|
||||
// exact hero action instead of moving focus to the home card behind it.
|
||||
if (detailsItem != null) return@launch
|
||||
if (returnRowId != null && returnItemId != null) {
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
@@ -2240,6 +2247,32 @@ private fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val playTrailer: (BaseItem) -> Unit = playTrailer@{ item ->
|
||||
if (launchingItem != null) return@playTrailer
|
||||
launchingItem = item
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "trailer", screen = "details",
|
||||
feature = "trailer", source = "details", target = "player",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
homeViewModel.flushAnalytics()
|
||||
val launched = runCatching {
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.trailerIntent(
|
||||
context,
|
||||
TrailerPlaybackRequest(
|
||||
subjectId = item.id,
|
||||
title = item.name,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
launchingItem = null
|
||||
Toast.makeText(context, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
val rows = remember(
|
||||
homeContent,
|
||||
@@ -3258,6 +3291,7 @@ private fun HomeScreen(
|
||||
detailsAiringNotice = null
|
||||
playItem(it)
|
||||
},
|
||||
onPlayTrailer = playTrailer,
|
||||
onToggleFavorite = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "favourite" else "unfavourite",
|
||||
@@ -3933,6 +3967,7 @@ private fun FocusedDetailsOverlay(
|
||||
selected: BaseItem,
|
||||
restorePosition: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -3947,6 +3982,7 @@ private fun FocusedDetailsOverlay(
|
||||
SeriesDetailsOverlay(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
@@ -3972,6 +4008,7 @@ private fun FocusedDetailsOverlay(
|
||||
MediaDetailsOverlay(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onClose = onClose,
|
||||
|
||||
@@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
|
||||
fun MediaDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
@@ -62,7 +63,7 @@ fun MediaDetailsOverlay(
|
||||
related = ServiceLocator.repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
|
||||
trailer = item.takeIf { ServiceLocator.repository.hasTrailer(item.id) }
|
||||
}
|
||||
LaunchedEffect(item.id, settings.showRatingsStrip) {
|
||||
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
|
||||
@@ -70,6 +71,7 @@ fun MediaDetailsOverlay(
|
||||
MediaDetailContent(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onOpenItem = onOpenItem,
|
||||
@@ -91,6 +93,7 @@ fun MediaDetailsOverlay(
|
||||
internal fun MediaDetailContent(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -201,7 +204,7 @@ internal fun MediaDetailContent(
|
||||
),
|
||||
)
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
|
||||
}
|
||||
add(
|
||||
DetailHeroAction(
|
||||
|
||||
@@ -103,6 +103,7 @@ import java.util.TimeZone
|
||||
fun SeriesDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -145,7 +146,7 @@ fun SeriesDetailsOverlay(
|
||||
related = repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = repository.getLocalTrailer(item.id)
|
||||
trailer = item.takeIf { repository.hasTrailer(item.id) }
|
||||
}
|
||||
LaunchedEffect(item.id, settings.showRatingsStrip) {
|
||||
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
|
||||
@@ -156,6 +157,7 @@ fun SeriesDetailsOverlay(
|
||||
episodes = episodes,
|
||||
loadFailed = loadFailed,
|
||||
onPlay = onPlay,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
@@ -178,6 +180,7 @@ internal fun SeriesDetailContent(
|
||||
episodes: List<BaseItem>?,
|
||||
loadFailed: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit = onPlay,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
@@ -353,7 +356,7 @@ internal fun SeriesDetailContent(
|
||||
))
|
||||
}
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
|
||||
}
|
||||
},
|
||||
) { visibleTab ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.app.Activity
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
@@ -77,12 +78,15 @@ import com.ponzischeme89.memby.data.normalizeSkipIntroMode
|
||||
import com.ponzischeme89.memby.data.resolveCast
|
||||
import com.ponzischeme89.memby.data.selectSubtitleId
|
||||
import com.ponzischeme89.memby.data.subtitleLabelWithFlag
|
||||
import com.ponzischeme89.memby.data.afterTrailerCandidate
|
||||
import com.ponzischeme89.memby.data.shouldFallbackTrailer
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
|
||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.playback.SurroundCodec
|
||||
@@ -169,6 +173,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
* re-prepare nothing.
|
||||
*/
|
||||
private var pendingRequest: PlaybackRequest? = null
|
||||
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
|
||||
private var trailerStartupTimeoutJob: Job? = null
|
||||
private var pendingResolveJob: Job? = null
|
||||
private var pendingResolveGeneration = 0L
|
||||
private var serviceAlertsMounted = false
|
||||
@@ -417,8 +423,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
val url = savedInstanceState?.getString(STATE_URL)?.takeIf(String::isNotBlank)
|
||||
?: intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST))
|
||||
pendingRequest = request.takeIf { url == null }
|
||||
if (url == null && request == null) {
|
||||
val trailerRequest = decodeTrailerRequest(
|
||||
savedInstanceState?.getString(STATE_TRAILER_REQUEST)
|
||||
?: intent.getStringExtra(EXTRA_TRAILER_REQUEST),
|
||||
)
|
||||
pendingTrailerRequest = trailerRequest
|
||||
pendingRequest = request.takeIf { url == null && trailerRequest == null }
|
||||
if (url == null && request == null && trailerRequest == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@@ -439,7 +450,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
val resumePositionMs = restoredPositionMs
|
||||
?: intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
|
||||
initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
|
||||
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
|
||||
val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true) && trailerRequest == null
|
||||
configuredPrerollDurationMs = intent.getLongExtra(
|
||||
EXTRA_PREROLL_DURATION_MS,
|
||||
DEFAULT_PREROLL_DURATION_MS,
|
||||
@@ -555,6 +566,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
prerollActive = false
|
||||
disposeLocalPreroll(reuse = false)
|
||||
prerollView?.visibility = View.GONE
|
||||
if (pendingTrailerRequest != null) {
|
||||
finishTrailerUnavailable()
|
||||
return
|
||||
}
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = "Couldn’t start the video player",
|
||||
@@ -639,6 +654,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
renderedFirstFrame = true
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
trailerStartupTimeoutJob = null
|
||||
endSeekBuffering()
|
||||
hidePlaybackLoading()
|
||||
if (!playbackStarted && !prerollActive) {
|
||||
@@ -670,6 +687,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
resumePositionMs,
|
||||
playWhenReady = restoredPlayWhenReady ?: !showPreroll,
|
||||
)
|
||||
} else if (trailerRequest != null) {
|
||||
resolvePendingTrailer()
|
||||
} else {
|
||||
resolvePendingStream(
|
||||
requireNotNull(request),
|
||||
@@ -726,6 +745,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
if (prepared.isFailure) {
|
||||
Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull())
|
||||
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
|
||||
fallbackToNextTrailer("prepare")
|
||||
return
|
||||
}
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = getString(R.string.playback_server_unreachable),
|
||||
@@ -740,6 +763,73 @@ class PlayerActivity : ComponentActivity() {
|
||||
endFirstFrameTrace()
|
||||
firstFrameTraceCookie = PlaybackTraceSections.nextCookie()
|
||||
PlaybackTraceSections.begin(PlaybackTraceSections.FIRST_FRAME, firstFrameTraceCookie)
|
||||
scheduleTrailerStartupTimeout()
|
||||
}
|
||||
|
||||
private fun resolvePendingTrailer() {
|
||||
val request = pendingTrailerRequest ?: return
|
||||
showPlaybackLoading(
|
||||
title = if (request.excludedCandidateIds.isEmpty()) "Finding trailer…" else "Finding another trailer…",
|
||||
hint = "Checking the best available source",
|
||||
)
|
||||
val generation = ++pendingResolveGeneration
|
||||
pendingResolveJob?.cancel()
|
||||
pendingResolveJob = lifecycleScope.launch {
|
||||
runCatching { ServiceLocator.repository.resolveTrailer(request) }
|
||||
.onSuccess { playable ->
|
||||
if (generation != pendingResolveGeneration || isFinishing || isDestroyed) return@onSuccess
|
||||
if (playable.url.isBlank() || playable.candidateId.isBlank()) {
|
||||
finishTrailerUnavailable()
|
||||
return@onSuccess
|
||||
}
|
||||
pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId)
|
||||
itemId = playable.itemId.takeIf(String::isNotBlank)
|
||||
mediaSourceId = playable.mediaSourceId
|
||||
playSessionId = playable.playSessionId
|
||||
playMethod = playable.playMethod
|
||||
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
setUpPlaybackIdentity(playbackTitle)
|
||||
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
if (generation != pendingResolveGeneration || isFinishing || isDestroyed) return@onFailure
|
||||
Log.w(PLAYBACK_LOG_TAG, "event=trailer_sources_exhausted subject=${request.subjectId}", error)
|
||||
finishTrailerUnavailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackToNextTrailer(reason: String) {
|
||||
if (!shouldFallbackTrailer(pendingTrailerRequest != null) || isFinishing || isDestroyed) return
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
trailerStartupTimeoutJob = null
|
||||
Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason")
|
||||
player?.apply {
|
||||
stop()
|
||||
clearMediaItems()
|
||||
}
|
||||
// Each candidate gets its own first-frame deadline. A previous provider may have
|
||||
// drawn briefly before failing, which must not exempt the replacement from it.
|
||||
renderedFirstFrame = false
|
||||
hidePlaybackError()
|
||||
resolvePendingTrailer()
|
||||
}
|
||||
|
||||
private fun scheduleTrailerStartupTimeout() {
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
if (pendingTrailerRequest == null || renderedFirstFrame) return
|
||||
trailerStartupTimeoutJob = lifecycleScope.launch {
|
||||
delay(TRAILER_STARTUP_TIMEOUT_MS)
|
||||
if (!renderedFirstFrame) fallbackToNextTrailer("startup_timeout")
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishTrailerUnavailable() {
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
setResult(RESULT_TRAILER_UNAVAILABLE, Intent().putExtra(EXTRA_TRAILER_UNAVAILABLE, true))
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun beginLaunchTrace() {
|
||||
@@ -1322,6 +1412,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun handlePlaybackError(error: PlaybackException) {
|
||||
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
|
||||
fallbackToNextTrailer("player_${error.errorCodeName}")
|
||||
return
|
||||
}
|
||||
if (prerollActive || fullscreenPlayerParent != null) {
|
||||
prerollActive = false
|
||||
findViewById<View>(R.id.player_preroll_video_host).animate().cancel()
|
||||
@@ -4060,6 +4154,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
outState.putString(STATE_POSTER_URL, pausePosterUrl)
|
||||
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
|
||||
outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs)
|
||||
pendingTrailerRequest?.let {
|
||||
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
|
||||
}
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
@@ -4360,6 +4457,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
||||
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
||||
private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request"
|
||||
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
|
||||
private const val EXTRA_TRAILER_UNAVAILABLE = "extra_trailer_unavailable"
|
||||
private const val STATE_POSITION_MS = "state_position_ms"
|
||||
private const val STATE_PLAY_WHEN_READY = "state_play_when_ready"
|
||||
private const val STATE_URL = "state_url"
|
||||
@@ -4380,11 +4479,14 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val STATE_POSTER_URL = "state_poster_url"
|
||||
private const val STATE_EPISODE_CODE = "state_episode_code"
|
||||
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
||||
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
||||
private const val PLAYER_PREFERENCES = "player_preferences"
|
||||
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||
private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f
|
||||
private val playerJson = Json { ignoreUnknownKeys = true }
|
||||
private const val TRAILER_STARTUP_TIMEOUT_MS = 8_000L
|
||||
private const val RESULT_TRAILER_UNAVAILABLE = Activity.RESULT_FIRST_USER + 17
|
||||
|
||||
/**
|
||||
* Starts playback without a stream, letting the player resolve one while it starts.
|
||||
@@ -4414,6 +4516,20 @@ class PlayerActivity : ComponentActivity() {
|
||||
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
|
||||
}
|
||||
|
||||
fun trailerIntent(context: Context, request: TrailerPlaybackRequest): Intent =
|
||||
Intent(context, PlayerActivity::class.java).apply {
|
||||
putExtra(EXTRA_TRAILER_REQUEST, playerJson.encodeToString(request))
|
||||
putExtra(EXTRA_ITEM_ID, request.subjectId)
|
||||
putExtra(EXTRA_TITLE, request.title + " trailer")
|
||||
request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) }
|
||||
putExtra(EXTRA_PREROLL_ENABLED, false)
|
||||
putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime())
|
||||
}
|
||||
|
||||
fun trailerUnavailable(resultCode: Int, data: Intent?): Boolean =
|
||||
resultCode == RESULT_TRAILER_UNAVAILABLE &&
|
||||
data?.getBooleanExtra(EXTRA_TRAILER_UNAVAILABLE, false) == true
|
||||
|
||||
fun intent(
|
||||
context: Context,
|
||||
url: String,
|
||||
@@ -4479,6 +4595,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
encoded?.let { runCatching { playerJson.decodeFromString<PlaybackRequest>(it) }.getOrNull() }
|
||||
?.takeIf { it.itemId.isNotBlank() }
|
||||
|
||||
private fun decodeTrailerRequest(encoded: String?): TrailerPlaybackRequest? =
|
||||
encoded?.let {
|
||||
runCatching { playerJson.decodeFromString<TrailerPlaybackRequest>(it) }.getOrNull()
|
||||
}?.takeIf { it.subjectId.isNotBlank() }
|
||||
|
||||
private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem {
|
||||
val configurations = subtitles.filter {
|
||||
it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank()
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
|
||||
/** In-app preview of the screensaver, launched from the home screen. */
|
||||
@@ -25,8 +26,13 @@ class ScreensaverActivity : ComponentActivity() {
|
||||
setContent {
|
||||
MembyTheme {
|
||||
ScreensaverContent(
|
||||
onPlay = { url, title ->
|
||||
startActivity(PlayerActivity.intent(this, url, title))
|
||||
onPlay = { item ->
|
||||
startActivity(
|
||||
PlayerActivity.trailerIntent(
|
||||
this,
|
||||
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
|
||||
),
|
||||
)
|
||||
},
|
||||
onExit = { finish() },
|
||||
startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE),
|
||||
|
||||
@@ -132,7 +132,7 @@ class ScreensaverActions {
|
||||
*/
|
||||
@Composable
|
||||
fun ScreensaverContent(
|
||||
onPlay: (url: String, title: String) -> Unit,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
actions: ScreensaverActions? = null,
|
||||
startupMessage: String? = null,
|
||||
@@ -163,7 +163,7 @@ fun ScreensaverContent(
|
||||
|
||||
@Composable
|
||||
private fun Slideshow(
|
||||
onPlay: (url: String, title: String) -> Unit,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
actions: ScreensaverActions?,
|
||||
showTitleLogo: Boolean,
|
||||
@@ -385,43 +385,13 @@ private fun Slideshow(
|
||||
if (playbackLaunching) return
|
||||
val target = item ?: return
|
||||
playbackLaunching = true
|
||||
toast = "Finding trailer…"
|
||||
scope.launch {
|
||||
runCatching { repo.getLocalTrailer(target.id) }
|
||||
.onSuccess { trailer ->
|
||||
if (trailer == null) {
|
||||
toast = "No trailer is available for ${target.name}."
|
||||
playbackLaunching = false
|
||||
} else {
|
||||
runCatching { repo.resolvePlayable(trailer) }
|
||||
.onSuccess { playable ->
|
||||
if (playable.url.isBlank()) {
|
||||
toast = "The trailer is unavailable right now."
|
||||
playbackLaunching = false
|
||||
} else if (runCatching {
|
||||
currentOnPlay(playable.url, "${target.name} trailer")
|
||||
}.isFailure
|
||||
) {
|
||||
toast = "Couldn’t start the trailer."
|
||||
playbackLaunching = false
|
||||
} else {
|
||||
// Keep rapid Select/media-key repeats gated while the
|
||||
// activity hand-off occurs, then re-arm on return.
|
||||
delay(1_000L)
|
||||
playbackLaunching = false
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
toast = friendlyEmbyError(it)
|
||||
playbackLaunching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
toast = friendlyEmbyError(it)
|
||||
playbackLaunching = false
|
||||
}
|
||||
if (runCatching { currentOnPlay(target) }.isFailure) {
|
||||
toast = "Couldn’t open the trailer."
|
||||
playbackLaunching = false
|
||||
return
|
||||
}
|
||||
// Keep rapid Select/media-key repeats gated while the Activity hand-off occurs.
|
||||
scope.launch { delay(1_000L); playbackLaunching = false }
|
||||
}
|
||||
|
||||
fun setFavorite(desired: Boolean) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayIntro
|
||||
import com.ponzischeme89.memby.data.model.GatewayTrickplay
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.GatewayTrailerAvailability
|
||||
import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -28,6 +30,21 @@ import org.junit.Test
|
||||
* side, this fails before a TV ever sees it.
|
||||
*/
|
||||
class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes trailer availability and resolved provider candidate`() {
|
||||
val availability = json.decodeFromString<GatewayTrailerAvailability>(
|
||||
"""{"available":true,"providers":["local","apple","youtube"]}""",
|
||||
)
|
||||
val playback = json.decodeFromString<GatewayTrailerPlayback>(
|
||||
"""{"candidateId":"youtube-a1","provider":"youtube","url":"https://media.example/trailer.mp4","title":"Arrival trailer","playMethod":"DirectPlay"}""",
|
||||
)
|
||||
|
||||
assertTrue(availability.available)
|
||||
assertEquals(listOf("local", "apple", "youtube"), availability.providers)
|
||||
assertEquals("youtube-a1", playback.candidateId)
|
||||
assertEquals("Arrival trailer", playback.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partially populated items decode without inventing an identity`() {
|
||||
val item = json.decodeFromString<BaseItem>(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TrailerSupportTest {
|
||||
@Test
|
||||
fun rejectedCandidatesAreRecordedOnceInOrder() {
|
||||
val request = TrailerPlaybackRequest("film", "A Film", excludedCandidateIds = listOf("apple"))
|
||||
|
||||
val next = afterTrailerCandidate(afterTrailerCandidate(request, "youtube"), "apple")
|
||||
|
||||
assertEquals(listOf("apple", "youtube"), next.excludedCandidateIds)
|
||||
assertEquals("film", next.subjectId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTrailerFailureUsesProviderFallback() {
|
||||
assertTrue(shouldFallbackTrailer(isTrailer = true))
|
||||
assertFalse(shouldFallbackTrailer(isTrailer = false))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user