0.2.57 - Traliers bug fixes

This commit is contained in:
ponzischeme89
2026-08-12 13:08:53 +12:00
parent f2d052dbf6
commit 64f19aeef2
45 changed files with 1215 additions and 681 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.56"
val defaultVersionName = "0.2.57"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -0,0 +1,359 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.remote.HttpStack
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
data class ResolvedRemoteTrailer(
val candidateId: String,
val provider: String,
val sourceUrl: String,
val url: String,
val title: String = "",
)
private data class CachedRemoteTrailer(val value: ResolvedRemoteTrailer, val expiresAtMs: Long)
/**
* Resolves remote trailer pages on the television rather than on the gateway.
*
* YouTube's signed media URLs may be tied to the address that requested them. Keeping both
* resolution and playback here means the provider sees the television's real public address,
* and the URL handed to Media3 is valid from the network that will actually fetch it.
*/
class ClientTrailerResolver(
private val http: OkHttpClient = HttpStack.base.newBuilder()
.connectTimeout(6, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build(),
private val nowMs: () -> Long = System::currentTimeMillis,
) {
private val cache = ConcurrentHashMap<String, CachedRemoteTrailer>()
suspend fun resolve(provider: String, sourceUrl: String): ResolvedRemoteTrailer =
withContext(Dispatchers.IO) {
val normalProvider = provider.trim().lowercase()
val source = sourceUrl.trim()
val key = "$normalProvider\u0000$source"
cache[key]?.takeIf { it.expiresAtMs > nowMs() }?.value?.let { return@withContext it }
cache.remove(key)
val resolved = when (normalProvider) {
"apple" -> resolveApple(source)
"youtube" -> resolveYouTube(source)
else -> error("Unsupported trailer provider")
}
cache[key] = CachedRemoteTrailer(resolved, nowMs() + CACHE_TTL_MS)
resolved
}
suspend fun searchNextEpisodePreview(next: NextEpisode): ResolvedRemoteTrailer? =
withContext(Dispatchers.IO) {
val key = "preview\u0000${next.seriesName}\u0000${next.episodeCode}\u0000${next.title}"
cache[key]?.takeIf { it.expiresAtMs > nowMs() }?.value?.let { return@withContext it }
cache.remove(key)
val query = previewSearchQuery(next)
val candidates = searchYouTube(query)
.sortedByDescending { previewScore(it, next) }
.filter { previewScore(it, next) >= MIN_PREVIEW_SCORE }
.take(MAX_PREVIEW_CANDIDATES)
for (candidate in candidates) {
val result = runCatching {
resolveYouTube("https://www.youtube.com/watch?v=${candidate.videoId}")
.copy(title = candidate.title)
}.getOrNull() ?: continue
cache[key] = CachedRemoteTrailer(result, nowMs() + CACHE_TTL_MS)
return@withContext result
}
null
}
fun invalidate(provider: String, sourceUrl: String) {
val normalProvider = provider.trim().lowercase()
val source = sourceUrl.trim()
cache.remove("$normalProvider\u0000$source")
cache.entries.forEach { (key, entry) ->
if (entry.value.provider == normalProvider && entry.value.sourceUrl == source) {
cache.remove(key, entry)
}
}
}
private fun resolveApple(source: String): ResolvedRemoteTrailer {
require(isAppleHost(source)) { "Unrecognised Apple trailer address" }
val direct = if (looksLikeMedia(source)) {
source
} else {
val page = getText(source, "text/html")
APPLE_MEDIA.findAll(page)
.map { it.value.replace("\\/", "/").replace("&amp;", "&") }
.distinct()
.sortedByDescending(::mediaQuality)
.firstOrNull { validateMedia(it) }
?: error("No playable Apple trailer")
}
require(validateMedia(direct)) { "Apple trailer is unavailable" }
return ResolvedRemoteTrailer(
candidateId = candidateId("apple", source),
provider = "apple",
sourceUrl = source,
url = direct,
)
}
private fun resolveYouTube(source: String): ResolvedRemoteTrailer {
val videoId = youtubeVideoId(source) ?: error("Unrecognised YouTube trailer address")
val playerBodies = sequenceOf(
innerTubePlayer(videoId),
runCatching { watchPagePlayer(videoId) }.getOrNull(),
).filterNotNull()
for (body in playerBodies) {
val root = runCatching { JSON.parseToJsonElement(body).jsonObject }.getOrNull() ?: continue
if (root.objectAt("playabilityStatus")?.stringAt("status") != "OK") continue
val formats = root.objectAt("streamingData")?.arrayAt("formats").orEmpty()
.mapNotNull { it as? JsonObject }
.sortedWith(compareByDescending<JsonObject> { it.intAt("height") }
.thenByDescending { it.intAt("bitrate") })
for (format in formats) {
val direct = format.stringAt("url")
val mime = format.stringAt("mimeType")
if (direct.isBlank() || !mime.startsWith("video/")) continue
if (validateMedia(direct)) {
return ResolvedRemoteTrailer(
candidateId = candidateId("youtube", source),
provider = "youtube",
sourceUrl = source,
url = direct,
)
}
}
}
error("YouTube trailer is unavailable")
}
private fun innerTubePlayer(videoId: String): String? {
val payload = """{"videoId":"$videoId","contentCheckOk":true,"racyCheckOk":true,"context":{"client":{"clientName":"ANDROID","clientVersion":"20.10.38","hl":"en","gl":"NZ"}}}"""
val request = Request.Builder()
.url("https://www.youtube.com/youtubei/v1/player")
.header("User-Agent", YOUTUBE_ANDROID_AGENT)
.post(payload.toRequestBody(JSON_MEDIA))
.build()
return executeText(request)
}
private fun watchPagePlayer(videoId: String): String? {
val page = getText(
"https://www.youtube.com/watch?v=$videoId&bpctr=9999999999&has_verified=1",
"text/html",
)
return listOf("ytInitialPlayerResponse = ", "\"playerResponse\":")
.firstNotNullOfOrNull { marker -> balancedObjectAfter(page, marker) }
}
private fun searchYouTube(query: String): List<YouTubeSearchCandidate> {
val encoded = URLEncoder.encode(query, StandardCharsets.UTF_8.name())
val page = getText("https://www.youtube.com/results?search_query=$encoded&hl=en&gl=NZ", "text/html")
return balancedObjectsAfter(page, "\"videoRenderer\":").mapNotNull { raw ->
val item = runCatching { JSON.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return@mapNotNull null
val videoId = item.stringAt("videoId").takeIf { it.length == 11 } ?: return@mapNotNull null
val title = item.rendererText("title").takeIf(String::isNotBlank) ?: return@mapNotNull null
val channel = item.rendererText("ownerText")
val seconds = durationSeconds(item.objectAt("lengthText")?.stringAt("simpleText").orEmpty())
YouTubeSearchCandidate(videoId, title, channel, seconds)
}.distinctBy(YouTubeSearchCandidate::videoId)
}
private fun getText(url: String, accept: String): String {
val request = Request.Builder().url(url)
.header("Accept", accept)
.header("User-Agent", WEB_AGENT)
.get().build()
return executeText(request) ?: error("Provider did not return a usable response")
}
private fun executeText(request: Request): String? = http.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) return@use null
response.peekBody(MAX_PAGE_BYTES).string()
}
private fun validateMedia(url: String): Boolean {
val request = runCatching {
Request.Builder().url(url)
.header("Range", "bytes=0-0")
.header("User-Agent", WEB_AGENT)
.get().build()
}.getOrNull() ?: return false
return runCatching {
http.newCall(request).execute().use { response ->
if (response.code != 200 && response.code != 206) return@use false
val type = response.header("Content-Type").orEmpty().substringBefore(';').lowercase()
type.startsWith("video/") || type in PLAYABLE_MANIFEST_TYPES || type == "application/octet-stream"
}
}.getOrDefault(false)
}
companion object {
private val JSON = Json { ignoreUnknownKeys = true }
private val JSON_MEDIA = "application/json".toMediaType()
private const val CACHE_TTL_MS = 30 * 60 * 1_000L
private const val MAX_PAGE_BYTES = 2L * 1024L * 1024L
private const val MAX_PREVIEW_CANDIDATES = 4
private const val MIN_PREVIEW_SCORE = 45
private const val YOUTUBE_ANDROID_AGENT =
"com.google.android.youtube/20.10.38 (Linux; U; Android 12) gzip"
private const val WEB_AGENT =
"Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 Chrome/122 Safari/537.36"
private val PLAYABLE_MANIFEST_TYPES = setOf(
"application/vnd.apple.mpegurl", "application/x-mpegurl",
)
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^\"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^\"'<> ]*)?""", RegexOption.IGNORE_CASE)
}
}
internal data class YouTubeSearchCandidate(
val videoId: String,
val title: String,
val channel: String,
val durationSeconds: Int,
)
internal fun previewSearchQuery(next: NextEpisode): String = buildString {
append(next.seriesName.trim())
next.episodeCode?.takeIf(String::isNotBlank)?.let { append(' ').append(it.replace(" ", "")) }
next.title.takeIf(String::isNotBlank)?.let { append(" \"").append(it.trim()).append('"') }
append(" official preview recap")
}
internal fun previewScore(candidate: YouTubeSearchCandidate, next: NextEpisode): Int {
val haystack = "${candidate.title} ${candidate.channel}".lowercase()
var score = 0
val seriesTokens = next.seriesName.lowercase().split(Regex("[^a-z0-9]+"))
.filter { it.length >= 3 }
score += seriesTokens.count(haystack::contains) * 7
val code = next.episodeCode.orEmpty().lowercase().replace(" ", "")
if (code.isNotBlank() && haystack.replace(" ", "").contains(code)) score += 30
val titleTokens = next.title.lowercase().split(Regex("[^a-z0-9]+"))
.filter { it.length >= 4 }
score += titleTokens.count(haystack::contains) * 4
if ("preview" in haystack || "promo" in haystack || "next on" in haystack) score += 28
if ("recap" in haystack || "previously on" in haystack) score += 22
if ("official" in haystack) score += 12
if (listOf("reaction", "review", "breakdown", "fan made", "explained").any(haystack::contains)) score -= 45
if (candidate.durationSeconds in 20..600) score += 8
if (candidate.durationSeconds > 1_200) score -= 25
return score
}
internal fun youtubeVideoId(raw: String): String? {
val uri = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
val host = uri.host?.lowercase()?.removePrefix("www.") ?: return null
val id = when {
host == "youtu.be" -> uri.path.trim('/')
host == "youtube.com" || host.endsWith(".youtube.com") ||
host == "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com") -> {
val queryId = uri.rawQuery.orEmpty().split('&').firstNotNullOfOrNull { pair ->
val parts = pair.split('=', limit = 2)
parts.getOrNull(1)?.takeIf { parts.firstOrNull() == "v" }
}
queryId ?: uri.path.trim('/').split('/').takeIf {
it.size == 2 && it.first() in setOf("embed", "shorts")
}?.last()
}
else -> null
}
return id?.takeIf { it.length == 11 && it.all { char -> char.isLetterOrDigit() || char == '-' || char == '_' } }
}
internal fun balancedObjectAfter(body: String, marker: String, fromIndex: Int = 0): String? {
val markerAt = body.indexOf(marker, fromIndex).takeIf { it >= 0 } ?: return null
val start = body.indexOf('{', markerAt + marker.length).takeIf { it >= 0 } ?: return null
var depth = 0
var quoted = false
var escaped = false
for (index in start until body.length) {
val char = body[index]
if (quoted) {
when {
escaped -> escaped = false
char == '\\' -> escaped = true
char == '"' -> quoted = false
}
continue
}
when (char) {
'"' -> quoted = true
'{' -> depth += 1
'}' -> if (--depth == 0) return body.substring(start, index + 1)
}
}
return null
}
internal fun balancedObjectsAfter(body: String, marker: String): List<String> {
val results = mutableListOf<String>()
var offset = 0
while (true) {
val markerAt = body.indexOf(marker, offset)
if (markerAt < 0) break
val objectAt = body.indexOf('{', markerAt + marker.length)
if (objectAt < 0) break
val value = balancedObjectAfter(body, marker, markerAt) ?: break
results += value
offset = objectAt + value.length
}
return results
}
private fun isAppleHost(raw: String): Boolean {
val host = runCatching { URI(raw).host?.lowercase()?.trimEnd('.') }.getOrNull() ?: return false
return host == "apple.com" || host.endsWith(".apple.com") || host == "apple.co" || host.endsWith(".apple.co")
}
private fun looksLikeMedia(raw: String): Boolean =
raw.substringBefore('?').lowercase().let { it.endsWith(".mov") || it.endsWith(".mp4") || it.endsWith(".m3u8") }
private fun mediaQuality(raw: String): Int =
listOf(2160, 1440, 1080, 720, 480, 360).firstOrNull { raw.contains(it.toString()) } ?: 0
private fun candidateId(provider: String, source: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest("$provider\u0000${source.trim()}".toByteArray())
return "$provider-" + digest.take(8).joinToString("") { "%02x".format(it) }
}
private fun durationSeconds(raw: String): Int {
val parts = raw.split(':').mapNotNull(String::toIntOrNull)
if (parts.isEmpty()) return 0
return parts.fold(0) { total, value -> total * 60 + value }
}
private fun JsonObject.stringAt(key: String): String =
this[key]?.jsonPrimitive?.contentOrNull.orEmpty()
private fun JsonObject.intAt(key: String): Int = this[key]?.jsonPrimitive?.intOrNull ?: 0
private fun JsonObject.objectAt(key: String): JsonObject? = this[key] as? JsonObject
private fun JsonObject.arrayAt(key: String): JsonArray? = this[key] as? JsonArray
private fun JsonObject.rendererText(key: String): String {
val renderer = objectAt(key) ?: return ""
renderer.stringAt("simpleText").takeIf(String::isNotBlank)?.let { return it }
return renderer.arrayAt("runs")?.firstOrNull()?.jsonObject?.stringAt("text").orEmpty()
}
@@ -211,6 +211,7 @@ data class PlaybackRequest(
)
class EmbyRepository(private val settings: SettingsStore) {
private val clientTrailerResolver = ClientTrailerResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -1458,24 +1459,68 @@ class EmbyRepository(private val settings: SettingsStore) {
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
var excluded = request.excludedCandidateIds.distinct()
repeat(MAX_TRAILER_CANDIDATES) {
val selected = runCatching {
requireGateway().resolveTrailer(
request.subjectId,
com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest(excluded),
)
}.recoverCatching { error ->
if (error is HttpException && error.code() == 404 && excluded.isEmpty()) {
resolveLegacyLocalTrailer(request)
} else {
throw error
}
}.getOrThrow()
if (selected.sourceUrl.isBlank()) return selected
val resolved = runCatching {
clientTrailerResolver.resolve(selected.provider, selected.sourceUrl)
}.getOrElse { error ->
reportTrailer(
request.subjectId, selected.candidateId, selected.provider,
phase = "failed", reason = "resolve_${error.javaClass.simpleName}",
)
excluded = (excluded + selected.candidateId).distinct()
return@repeat
}
}.getOrThrow()
return selected.copy(url = resolved.url)
}
throw NoSuchElementException("No playable trailer is available")
}
return resolveLegacyLocalTrailer(request)
}
suspend fun nextEpisodePreview(next: NextEpisode): ResolvedRemoteTrailer? =
runCatching { clientTrailerResolver.searchNextEpisodePreview(next) }.getOrNull()
fun reportTrailer(
itemId: String,
candidateId: String,
provider: String,
phase: String,
reason: String = "",
) {
if (!ServerConfig.isGateway || itemId.isBlank() || candidateId.isBlank()) return
scope.launch {
runCatching {
requireGateway().reportTrailer(
itemId,
com.ponzischeme89.memby.data.model.GatewayTrailerReport(
candidateId = candidateId,
provider = provider,
phase = phase,
reason = reason,
),
)
}
}
}
fun rejectTrailerSource(provider: String, sourceUrl: String) {
if (sourceUrl.isNotBlank()) clientTrailerResolver.invalidate(provider, sourceUrl)
}
private suspend fun resolveLegacyLocalTrailer(
request: com.ponzischeme89.memby.data.model.TrailerPlaybackRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback {
@@ -2848,6 +2893,7 @@ private const val RELATED_LIMIT = 12
* point of the cache is that walking back into a page never asks again.
*/
private const val TRAILER_CACHE_SIZE = 64
private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
// short enough that a newly watched title drops out of "more like this" the same evening.
@@ -317,6 +317,8 @@ data class Settings(
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
val autoPlayNextEpisode: Boolean = true,
// Play a short, matched YouTube recap or preview before rolling into the next episode.
val playNextEpisodePreview: Boolean = false,
// Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true,
// Turn a subtitle track on automatically, and which language wins when one is chosen.
@@ -490,6 +492,7 @@ data class EmbyProfile(
/** Playback and presentation choices, which sync alongside the home ones. */
val showTitleLogo: Boolean = true,
val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
@@ -539,6 +542,7 @@ class SettingsStore(private val context: Context) {
val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
val PLAY_NEXT_EPISODE_PREVIEW = booleanPreferencesKey("play_next_episode_preview")
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
@@ -671,6 +675,13 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setPlayNextEpisodePreview(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = enabled
updateActiveProfile(preferences) { it.copy(playNextEpisodePreview = enabled) }
}
}
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled
@@ -799,6 +810,7 @@ class SettingsStore(private val context: Context) {
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.THEME_ID] = preferences.themeId
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
store[Keys.PLAY_NEXT_EPISODE_PREVIEW] = preferences.playNextEpisodePreview
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
@@ -823,6 +835,7 @@ class SettingsStore(private val context: Context) {
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
themeId = preferences.themeId,
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
playNextEpisodePreview = preferences.playNextEpisodePreview,
showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled,
subtitleLanguage = preferences.subtitleLanguage,
@@ -1249,6 +1262,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = previous?.preferencesRevision ?: 0,
showTitleLogo = previous?.showTitleLogo ?: true,
autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
playNextEpisodePreview = previous?.playNextEpisodePreview ?: false,
showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
subtitlesEnabled = previous?.subtitlesEnabled ?: true,
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
@@ -1340,6 +1354,7 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.HOME_HIDDEN_ROWS)
preferences.remove(Keys.SHOW_TITLE_LOGO)
preferences.remove(Keys.AUTO_PLAY_NEXT)
preferences.remove(Keys.PLAY_NEXT_EPISODE_PREVIEW)
preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
preferences.remove(Keys.SUBTITLES_ENABLED)
preferences.remove(Keys.SUBTITLE_LANGUAGE)
@@ -1397,6 +1412,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
preferences[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode
preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] = profile.playNextEpisodePreview
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
@@ -1439,6 +1455,7 @@ class SettingsStore(private val context: Context) {
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
@@ -1476,6 +1493,7 @@ class SettingsStore(private val context: Context) {
confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
playNextEpisodePreview = preferences[Keys.PLAY_NEXT_EPISODE_PREVIEW] ?: false,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
@@ -14,3 +14,11 @@ internal fun afterTrailerCandidate(
/** Trailer failures always advance the provider chain instead of opening the generic error pane. */
internal fun shouldFallbackTrailer(isTrailer: Boolean): Boolean = isTrailer
internal fun shouldStartNextEpisodePreview(
armed: Boolean,
dismissed: Boolean,
remainingMs: Long,
leadMs: Long,
hasPreview: Boolean,
): Boolean = armed && !dismissed && hasPreview && remainingMs in 1L..leadMs
@@ -44,6 +44,7 @@ data class UserPreferences(
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
val autoPlayNextEpisode: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
/**
* Whether a subtitle track is turned on automatically, and which language wins when it
@@ -86,6 +87,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = themeId,
autoPlayNextEpisode = autoPlayNextEpisode,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
@@ -128,6 +130,7 @@ fun decodeUserPreferences(
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
themeId = json.string("themeId", fallback.themeId),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
playNextEpisodePreview = json.boolean("playNextEpisodePreview", fallback.playNextEpisodePreview),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
@@ -161,6 +164,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("themeId", themeId)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("playNextEpisodePreview", playNextEpisodePreview)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
@@ -190,4 +194,3 @@ private fun JsonObject.stringList(key: String, fallback: List<String>): List<Str
(element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct()
}
@@ -98,7 +98,11 @@ data class DeviceProfile(
audio: DeviceAudioCapabilities = deviceAudioCapabilities,
passthrough: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
) = DeviceProfile(
name = "Memby Android TV",
// Emby records this against the playback session and shows it in its device
// list, so it is the client identity on the wire rather than the product
// name, and it must match what the gateway sends (deviceProfileName) or one
// television playing both ways appears as two.
name = "MbyATV",
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
@@ -58,6 +58,7 @@ data class GatewayTrailerPlayback(
val candidateId: String = "",
val provider: String = "",
val url: String = "",
val sourceUrl: String = "",
val title: String = "",
val itemId: String = "",
val mediaSourceId: String = "",
@@ -71,9 +72,18 @@ data class TrailerPlaybackRequest(
val subjectId: String,
val title: String,
val posterUrl: String? = null,
val logoUrl: String? = null,
val excludedCandidateIds: List<String> = emptyList(),
)
@Serializable
data class GatewayTrailerReport(
val candidateId: String,
val provider: String,
val phase: String,
val reason: String = "",
)
@Serializable
data class GatewayDeviceNameRequest(val deviceName: String)
@@ -302,6 +302,12 @@ interface GatewayApi {
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerResolveRequest,
): com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
@POST("v1/items/{id}/trailers/report")
suspend fun reportTrailer(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewayTrailerReport,
)
@POST("v1/items/{id}/favorite")
suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@@ -118,7 +118,11 @@ class MembyDreamService : DreamService() {
private fun launchTrailer(item: BaseItem) {
val intent = PlayerActivity.trailerIntent(
applicationContext,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
),
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
finish()
@@ -2264,6 +2264,7 @@ private fun HomeScreen(
subjectId = item.id,
title = item.name,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
logoUrl = repo.logoUrl(item, maxWidth = 720),
),
),
)
@@ -65,6 +65,7 @@ import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest
@@ -80,6 +81,7 @@ 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.shouldStartNextEpisodePreview
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -175,6 +177,10 @@ class PlayerActivity : ComponentActivity() {
private var pendingRequest: PlaybackRequest? = null
private var pendingTrailerRequest: TrailerPlaybackRequest? = null
private var trailerStartupTimeoutJob: Job? = null
private var currentTrailerCandidateId = ""
private var currentTrailerProvider = ""
private var currentTrailerSourceUrl = ""
private var currentTrailerStartedReported = false
private var pendingResolveJob: Job? = null
private var pendingResolveGeneration = 0L
private var serviceAlertsMounted = false
@@ -281,6 +287,25 @@ class PlayerActivity : ComponentActivity() {
private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: Job? = null
private var nextEpisodePreviewLookupJob: Job? = null
private var nextEpisodePreviewTimeoutJob: Job? = null
private var nextEpisodePreview: ResolvedRemoteTrailer? = null
private var previewNextEpisode: NextEpisode? = null
private var previewWindowArmed = false
private var playingNextEpisodePreview = false
private var previewOutgoingReported = false
private var previewResumeUrl = ""
private var previewResumeSubtitles: List<PlayableSubtitle> = emptyList()
private var previewResumePositionMs = 0L
private var previewResumeDurationMs = 0L
private var previewResumeTitle = ""
private var previewResumeLogoUrl: String? = null
private var previewResumePosterUrl: String? = null
private var previewResumeOverview = ""
private var previewResumePlaybackStarted = false
private var previewResumeStopReported = false
private var currentMediaUrl = ""
private var currentMediaSubtitles: List<PlayableSubtitle> = emptyList()
private var nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null
private var nextUpDismissed = false
@@ -658,7 +683,34 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob = null
endSeekBuffering()
hidePlaybackLoading()
if (!playbackStarted && !prerollActive) {
pendingTrailerRequest?.let { request ->
if (!currentTrailerStartedReported && currentTrailerCandidateId.isNotBlank()) {
currentTrailerStartedReported = true
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "started",
)
}
}
if (playingNextEpisodePreview) {
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
reportNextEpisodePreview("started")
if (!previewOutgoingReported) {
previewOutgoingReported = true
val completedId = itemId
if (!completedId.isNullOrBlank() && previewResumePlaybackStarted) {
PlaybackStopWorker.enqueue(
this@PlayerActivity,
playbackSession(completedId),
previewResumeDurationMs,
)
}
}
}
if (!playbackStarted && !prerollActive && !playingNextEpisodePreview) {
startPlaybackSession(playback)
}
if (prerollActive && localPrerollPlayer == null) {
@@ -737,6 +789,8 @@ class PlayerActivity : ComponentActivity() {
playWhenReady: Boolean,
) {
val playback = player ?: return
currentMediaUrl = url
currentMediaSubtitles = subtitles
val prepared = runCatching {
require(url.isNotBlank()) { "Playback URL is blank" }
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
@@ -745,6 +799,10 @@ class PlayerActivity : ComponentActivity() {
}
if (prepared.isFailure) {
Log.e(PLAYBACK_LOG_TAG, "event=media_prepare_failed item=${itemId.orEmpty()}", prepared.exceptionOrNull())
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("prepare")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("prepare")
return
@@ -783,6 +841,10 @@ class PlayerActivity : ComponentActivity() {
return@onSuccess
}
pendingTrailerRequest = afterTrailerCandidate(request, playable.candidateId)
currentTrailerCandidateId = playable.candidateId
currentTrailerProvider = playable.provider
currentTrailerSourceUrl = playable.sourceUrl
currentTrailerStartedReported = false
itemId = playable.itemId.takeIf(String::isNotBlank)
mediaSourceId = playable.mediaSourceId
playSessionId = playable.playSessionId
@@ -806,6 +868,18 @@ class PlayerActivity : ComponentActivity() {
trailerStartupTimeoutJob?.cancel()
trailerStartupTimeoutJob = null
Log.w(PLAYBACK_LOG_TAG, "event=trailer_fallback reason=$reason")
pendingTrailerRequest?.let { request ->
if (currentTrailerCandidateId.isNotBlank()) {
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "failed",
reason = reason,
)
ServiceLocator.repository.rejectTrailerSource(currentTrailerProvider, currentTrailerSourceUrl)
}
}
player?.apply {
stop()
clearMediaItems()
@@ -1412,6 +1486,10 @@ class PlayerActivity : ComponentActivity() {
}
private fun handlePlaybackError(error: PlaybackException) {
if (playingNextEpisodePreview) {
resumeEpisodeAfterPreviewFailure("player_${error.errorCodeName}")
return
}
if (shouldFallbackTrailer(pendingTrailerRequest != null)) {
fallbackToNextTrailer("player_${error.errorCodeName}")
return
@@ -2586,11 +2664,14 @@ class PlayerActivity : ComponentActivity() {
*/
private fun prefetchNextEpisode() {
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
val id = itemId?.takeIf { it.isNotBlank() } ?: return
nextEpisodeLookupJob = lifecycleScope.launch {
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode
val resolved = if (enabled) {
val playbackSettings = ServiceLocator.repository.settingsFlow.first()
val resolved = if (playbackSettings.autoPlayNextEpisode) {
// A next episode with no stream behind it is not a next episode. Kept as one
// it would put up a banner and a countdown promising something that cannot be
// played, and then — because the countdown runs itself out — swap it in
@@ -2612,6 +2693,9 @@ class PlayerActivity : ComponentActivity() {
// Playback may have moved on to another episode while this was in flight.
if (itemId == id) {
nextEpisode = resolved
if (resolved != null && playbackSettings.playNextEpisodePreview) {
prefetchNextEpisodePreview(id, resolved)
}
// Extremely short episodes and hostile latency can reach Ended before the
// lookup. The ended frame waits for this answer rather than leaving Home.
if (player?.playbackState == Player.STATE_ENDED) {
@@ -2625,6 +2709,16 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun prefetchNextEpisodePreview(currentItemId: String, next: NextEpisode) {
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = lifecycleScope.launch {
val preview = ServiceLocator.repository.nextEpisodePreview(next)
if (itemId == currentItemId && nextEpisode?.itemId == next.itemId) {
nextEpisodePreview = preview
}
}
}
/**
* Watches the media playhead rather than wall-clock time. This makes the transition
* deterministic: pause freezes the countdown and seeking out of the final minute
@@ -2641,16 +2735,26 @@ class PlayerActivity : ComponentActivity() {
}
private fun updateNextUpFromPlayhead() {
if (advancing) return
if (advancing || playingNextEpisodePreview) return
val playback = player ?: return
val next = nextEpisode ?: return
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return
// Evaluated first, because whether the pane is up decides what the banner may do.
updateEndCreditsFromPlayhead(playback, next)
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
if (remainingMs > NEXT_EPISODE_PREVIEW_LEAD_MS) previewWindowArmed = true
if (shouldStartNextEpisodePreview(
armed = previewWindowArmed,
dismissed = nextUpDismissed,
remainingMs = remainingMs,
leadMs = NEXT_EPISODE_PREVIEW_LEAD_MS,
hasPreview = nextEpisodePreview != null,
) && startNextEpisodePreview(playback, next)
) return
// Evaluated after the preview: both take over the closing-credits presentation, and
// a resolved preview is the more specific next-episode experience.
updateEndCreditsFromPlayhead(playback, next)
// The pane says what is on next already, and shrinks the same picture by a different
// amount. Raising the banner over it would fight for the transform and print the
// episode twice, so the countdown moves into the pane instead — and it is only worth
@@ -2683,6 +2787,112 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun startNextEpisodePreview(playback: Player, next: NextEpisode): Boolean {
val preview = nextEpisodePreview ?: return false
if (playingNextEpisodePreview || preview.url.isBlank()) return false
previewResumeUrl = currentMediaUrl
previewResumeSubtitles = currentMediaSubtitles
previewResumePositionMs = playback.currentPosition.coerceAtLeast(0L)
previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
previewResumeTitle = playbackTitle
previewResumeLogoUrl = logoUrl
previewResumePosterUrl = pausePosterUrl
previewResumeOverview = pauseOverview
previewResumePlaybackStarted = playbackStarted
previewResumeStopReported = stopReported
previewOutgoingReported = false
previewNextEpisode = next
playingNextEpisodePreview = true
stopReported = true // Prevent preview positions being reported against the episode.
stopProgressUploading()
hideNextUp()
if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}"
logoUrl = next.logoUrl
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = lifecycleScope.launch {
delay(NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS)
if (playingNextEpisodePreview && !renderedFirstFrame) {
resumeEpisodeAfterPreviewFailure("startup_timeout")
}
}
return true
}
private fun resumeEpisodeAfterPreviewFailure(reason: String) {
if (!playingNextEpisodePreview) return
val next = previewNextEpisode
nextEpisodePreview?.let {
ServiceLocator.repository.rejectTrailerSource(it.provider, it.sourceUrl)
}
reportNextEpisodePreview("failed", reason)
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
nextEpisodePreview = null
playingNextEpisodePreview = false
previewNextEpisode = null
if (previewOutgoingReported) {
next?.let(::playNext) ?: returnHomeAfterCompletion()
return
}
playbackTitle = previewResumeTitle
logoUrl = previewResumeLogoUrl
pausePosterUrl = previewResumePosterUrl
pauseOverview = previewResumeOverview
playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
renderedFirstFrame = false
if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview
// must never strand the viewer on a generic error pane.
next?.let(::playNext) ?: finish()
return
}
showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode")
startMedia(
previewResumeUrl,
previewResumeSubtitles,
previewResumePositionMs,
playWhenReady = true,
)
if (playbackStarted && !stopReported) startProgressReporting()
}
private fun reportNextEpisodePreview(phase: String, reason: String = "") {
val preview = nextEpisodePreview ?: return
val subject = previewNextEpisode?.itemId ?: return
ServiceLocator.repository.reportTrailer(
subject,
preview.candidateId,
preview.provider,
phase,
reason,
)
}
private fun completeNextEpisodePreview() {
if (!playingNextEpisodePreview) return
reportNextEpisodePreview("completed")
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
val next = previewNextEpisode
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
next?.let(::playNext) ?: returnHomeAfterCompletion()
}
@OptIn(UnstableApi::class)
private fun showNextUp(next: NextEpisode) {
val banner = nextUpBanner ?: return
@@ -3012,6 +3222,20 @@ class PlayerActivity : ComponentActivity() {
}
private fun handlePlaybackEnded() {
if (playingNextEpisodePreview) {
completeNextEpisodePreview()
return
}
pendingTrailerRequest?.let { request ->
if (currentTrailerCandidateId.isNotBlank()) {
ServiceLocator.repository.reportTrailer(
request.subjectId,
currentTrailerCandidateId,
currentTrailerProvider,
phase = "completed",
)
}
}
when (playbackCompletionAction(
hasNextEpisode = nextEpisode != null,
nextUpDismissed = nextUpDismissed,
@@ -3057,7 +3281,17 @@ class PlayerActivity : ComponentActivity() {
advancing = true
nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodeLookupJob = null
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = null
nextEpisodePreviewTimeoutJob?.cancel()
nextEpisodePreviewTimeoutJob = null
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
retryGeneration += 1
retryJob?.cancel()
subtitleStreamGeneration += 1
@@ -4131,16 +4365,22 @@ class PlayerActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
player?.takeUnless { relaunchingForNewIntent }?.let { playback ->
outState.putLong(STATE_POSITION_MS, playback.currentPosition.coerceAtLeast(0L))
val savingPreview = playingNextEpisodePreview && previewResumeUrl.isNotBlank()
outState.putLong(
STATE_POSITION_MS,
if (savingPreview) previewResumePositionMs else playback.currentPosition.coerceAtLeast(0L),
)
outState.putBoolean(STATE_PLAY_WHEN_READY, playback.playWhenReady)
playback.currentMediaItem?.localConfiguration?.uri?.toString()?.takeIf(String::isNotBlank)
(if (savingPreview) previewResumeUrl else playback.currentMediaItem?.localConfiguration?.uri?.toString())
?.takeIf(String::isNotBlank)
?.let { outState.putString(STATE_URL, it) }
itemId?.let { outState.putString(STATE_ITEM_ID, it) }
outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId)
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
outState.putString(STATE_PLAY_METHOD, playMethod)
if (availableSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(availableSubtitles))
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
if (savedSubtitles.isNotEmpty()) {
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
}
outState.putBoolean(STATE_SUBTITLES_ENABLED, subtitlePreference == true)
outState.putString(STATE_SELECTED_SUBTITLE_ID, serverSubtitleId)
@@ -4148,10 +4388,10 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, playbackTitle)
outState.putString(STATE_LOGO_URL, logoUrl)
outState.putString(STATE_OVERVIEW, pauseOverview)
outState.putString(STATE_POSTER_URL, pausePosterUrl)
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
outState.putLong(STATE_RUNTIME_MS, prerollRuntimeMs)
pendingTrailerRequest?.let {
@@ -4250,6 +4490,8 @@ class PlayerActivity : ComponentActivity() {
encodedSubtitleJob?.cancel()
nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewTimeoutJob?.cancel()
creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel()
retryJob?.cancel()
@@ -4522,6 +4764,7 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_ITEM_ID, request.subjectId)
putExtra(EXTRA_TITLE, request.title + " trailer")
request.posterUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_POSTER_URL, it) }
request.logoUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_LOGO_URL, it) }
putExtra(EXTRA_PREROLL_ENABLED, false)
putExtra(EXTRA_REQUEST_STARTED_AT_MS, SystemClock.elapsedRealtime())
}
@@ -4683,6 +4926,8 @@ class PlayerActivity : ComponentActivity() {
/** Show against the still-playing final minute, using media time for determinism. */
private const val NEXT_UP_LEAD_MS = 60_000L
private const val NEXT_UP_TICK_MS = 250L
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L
private const val NEXT_UP_VIDEO_SCALE = 0.58f
private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
@@ -30,7 +30,11 @@ class ScreensaverActivity : ComponentActivity() {
startActivity(
PlayerActivity.trailerIntent(
this,
TrailerPlaybackRequest(subjectId = item.id, title = item.name),
TrailerPlaybackRequest(
subjectId = item.id,
title = item.name,
logoUrl = com.ponzischeme89.memby.ServiceLocator.repository.logoUrl(item, 720),
),
),
)
},
@@ -233,6 +233,7 @@ private val RailEdge = Color.White.copy(alpha = 0.10f)
internal data class SettingsPanelState(
val showLogo: Boolean = true,
val autoPlayNext: Boolean = true,
val playNextEpisodePreview: Boolean = false,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
@@ -293,6 +294,7 @@ internal data class SettingsPanelActions(
val onClose: () -> Unit = {},
val onShowLogoChanged: (Boolean) -> Unit = {},
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onPlayNextEpisodePreviewChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
@@ -354,6 +356,7 @@ fun SettingsSheet(
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var playNextEpisodePreview by rememberSaveable { mutableStateOf(settings.playNextEpisodePreview) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
@@ -448,6 +451,7 @@ fun SettingsSheet(
settings.hideWatchedMovies,
settings.confirmExitMemby,
settings.autoPlayNextEpisode,
settings.playNextEpisodePreview,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
@@ -458,6 +462,7 @@ fun SettingsSheet(
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
playNextEpisodePreview = settings.playNextEpisodePreview
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
@@ -486,6 +491,7 @@ fun SettingsSheet(
val state = SettingsPanelState(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
playNextEpisodePreview = playNextEpisodePreview,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
@@ -535,6 +541,11 @@ fun SettingsSheet(
autoPlayNext = it
persistSetting { store.setAutoPlayNextEpisode(it) }
},
onPlayNextEpisodePreviewChanged = {
onAnalyticsEvent("next_episode_preview", "toggle")
playNextEpisodePreview = it
persistSetting { store.setPlayNextEpisodePreview(it) }
},
onShowTenMinuteReminderChanged = {
onAnalyticsEvent("playback_reminder", "toggle")
showTenMinuteReminder = it
@@ -955,6 +966,14 @@ internal fun SettingsPanelContent(
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Next-episode recap or preview",
description = "With auto-play on, find a matching YouTube recap or preview and play it " +
"two minutes before the episode ends.",
checked = state.playNextEpisodePreview,
onCheckedChange = actions.onPlayNextEpisodePreviewChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Opening titles",
description = "What to do when an episode reaches its intro.",
@@ -0,0 +1,52 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ClientTrailerResolverTest {
@Test
fun youtubeIdsAcceptRealHostsAndRejectLookalikes() {
val id = "dQw4w9WgXcQ"
assertEquals(id, youtubeVideoId("https://www.youtube.com/watch?v=$id"))
assertEquals(id, youtubeVideoId("https://youtu.be/$id"))
assertEquals(id, youtubeVideoId("https://www.youtube-nocookie.com/embed/$id"))
assertNull(youtubeVideoId("https://notyoutube.com/watch?v=$id"))
}
@Test
fun balancedJsonIgnoresBracesInsideStrings() {
val body = "prefix \"videoRenderer\":{\"title\":\"a } brace\",\"nested\":{\"ok\":true}} suffix"
assertEquals(
"{\"title\":\"a } brace\",\"nested\":{\"ok\":true}}",
balancedObjectAfter(body, "\"videoRenderer\":"),
)
}
@Test
fun smartPreviewRankingFavoursTheExactOfficialEpisode() {
val next = NextEpisode(
itemId = "episode-2",
title = "The Crossing",
seriesName = "Southern Skies",
episodeCode = "S02 E04",
imageUrl = null,
url = "https://emby/episode-2",
)
val official = YouTubeSearchCandidate(
videoId = "abcdefghijk",
title = "Southern Skies S02E04 The Crossing Official Preview",
channel = "Southern Skies",
durationSeconds = 95,
)
val reaction = YouTubeSearchCandidate(
videoId = "zyxwvutsrqp",
title = "Southern Skies S02E04 preview reaction and breakdown",
channel = "Fan Reviews",
durationSeconds = 900,
)
assertTrue(previewScore(official, next) > previewScore(reaction, next))
assertTrue(previewSearchQuery(next).contains("S02E04"))
}
}
@@ -36,12 +36,13 @@ class GatewayPayloadTest {
"""{"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"}""",
"""{"candidateId":"youtube-a1","provider":"youtube","sourceUrl":"https://youtu.be/dQw4w9WgXcQ","title":"Arrival trailer","playMethod":"DirectPlay"}""",
)
assertTrue(availability.available)
assertEquals(listOf("local", "apple", "youtube"), availability.providers)
assertEquals("youtube-a1", playback.candidateId)
assertEquals("https://youtu.be/dQw4w9WgXcQ", playback.sourceUrl)
assertEquals("Arrival trailer", playback.title)
}
@@ -20,6 +20,16 @@ class PlaybackReportMathTest {
assertEquals(0L, millisecondsToTicks(-1L))
}
/**
* Emby lists the device profile's name against a playback session, so it must carry
* the client identity rather than the product name and it must be the same literal
* the gateway sends, or one television playing both ways appears as two clients.
*/
@Test
fun deviceProfileReportsTheClientIdentityNotTheProductName() {
assertEquals("MbyATV", DeviceProfile.embyAndroidTv().name)
}
@Test
fun deviceProfileExternalizesTextAndEncodesBitmapSubtitles() {
val profiles = DeviceProfile.embyAndroidTv().subtitleProfiles.associate {
@@ -22,4 +22,12 @@ class TrailerSupportTest {
assertTrue(shouldFallbackTrailer(isTrailer = true))
assertFalse(shouldFallbackTrailer(isTrailer = false))
}
@Test
fun nextEpisodePreviewNeedsANaturalTwoMinuteWindow() {
assertTrue(shouldStartNextEpisodePreview(true, false, 120_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(false, false, 60_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(true, true, 60_000, 120_000, true))
assertFalse(shouldStartNextEpisodePreview(true, false, 120_001, 120_000, true))
}
}
@@ -27,6 +27,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "homicidal",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
skipIntroMode = SKIP_INTRO_AUTO,
@@ -142,6 +143,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = "recommended\nlatest",
@@ -160,6 +162,7 @@ class UserPreferencesTest {
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
playNextEpisodePreview = true,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = listOf("recommended", "latest"),