0.2.56 - Reliable trailer playback

This commit is contained in:
ponzischeme89
2026-08-12 11:05:07 +12:00
parent 9777eb0952
commit f2d052dbf6
22 changed files with 1438 additions and 55 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.54"
val defaultVersionName = "0.2.56"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -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, "Couldnt 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 = "Couldnt 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 = "Couldnt 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 = "Couldnt 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))
}
}