0.2.57 - Traliers bug fixes
This commit is contained in:
@@ -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("&", "&") }
|
||||
.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"),
|
||||
|
||||
Reference in New Issue
Block a user