This commit is contained in:
ponzischeme89
2026-08-27 07:31:57 +12:00
parent 394a6d9b57
commit b0b8990f90
24 changed files with 388 additions and 477 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.32"
val defaultVersionName = "0.3.33"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -223,7 +223,7 @@ class ClientTrailerResolver(
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)
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?""", RegexOption.IGNORE_CASE)
}
}
@@ -464,6 +464,11 @@ class EmbyRepository internal constructor(
val api = EmbyServiceFactory.create(
baseUrl = base,
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
deviceNameProvider = {
snapshot.deviceName.ifBlank {
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
}
},
tokenProvider = { snapshot.token },
)
cachedApi = api
@@ -477,6 +482,11 @@ class EmbyRepository internal constructor(
val api = EmbyServiceFactory.create(
baseUrl = base,
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
deviceNameProvider = {
snapshot.deviceName.ifBlank {
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
}
},
tokenProvider = { snapshot.token },
prioritisePlayback = true,
)
@@ -333,6 +333,11 @@ class SearchRepository internal constructor(
return EmbyServiceFactory.create(
baseUrl = baseUrl,
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
deviceNameProvider = {
snapshot.deviceName.ifBlank {
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
}
},
tokenProvider = { snapshot.token },
).also { api ->
cachedApi = api
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs
import com.ponzischeme89.memby.data.remote.EmbyIdentity
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -102,7 +103,7 @@ data class DeviceProfile(
// 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",
name = EmbyIdentity.CLIENT_NAME,
subtitleProfiles = listOf(
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
).map { SubtitleProfile(it, "External") } + listOf(
@@ -0,0 +1,20 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.BuildConfig
/**
* The identity Memby presents to Emby.
*
* This is deliberately not the product name: Emby records it in device and session lists, so
* every direct-path request and playback profile must agree on the same wire identity.
*/
object EmbyIdentity {
const val CLIENT_NAME = "MbyATV"
const val FALLBACK_DEVICE_NAME = "Android TV"
fun authorisationHeader(deviceName: String, deviceId: String): String =
"MediaBrowser Client=\"$CLIENT_NAME\", " +
"Device=\"${deviceName.replace("\"", "")}\", " +
"DeviceId=\"${deviceId.replace("\"", "")}\", " +
"Version=\"${BuildConfig.VERSION_NAME}\""
}
@@ -1,7 +1,7 @@
package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
@@ -22,6 +22,7 @@ object EmbyServiceFactory {
fun create(
baseUrl: String,
deviceIdProvider: () -> String,
deviceNameProvider: () -> String,
tokenProvider: () -> String?,
prioritisePlayback: Boolean = false,
): EmbyApi {
@@ -41,7 +42,7 @@ object EmbyServiceFactory {
val client = (if (prioritisePlayback) HttpStack.playback else HttpStack.base).newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, deviceNameProvider, tokenProvider))
.addInterceptor(DiagnosticNetworkInterceptor("emby"))
.addInterceptor(logging)
.build()
@@ -58,22 +59,27 @@ object EmbyServiceFactory {
/** Adds the Emby auth headers to every request. */
private class EmbyAuthInterceptor(
private val deviceIdProvider: () -> String,
private val deviceNameProvider: () -> String,
private val tokenProvider: () -> String?,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val deviceId = deviceIdProvider()
// Version comes from the build, so Emby's device list shows which release a TV
// is actually running. The client name is deliberately not the app's own — this
// header leaves the house with whatever Emby does with its logs — and must match
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
// ways would appear as two clients.
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
val deviceName = deviceNameProvider().ifBlank { EmbyIdentity.FALLBACK_DEVICE_NAME }
val authHeader = EmbyIdentity.authorisationHeader(deviceName, deviceId)
val builder = chain.request().newBuilder()
.header("X-Emby-Authorization", authHeader)
.header("Accept", "application/json")
if (MembyDiagnostics.debugEnabled) {
MembyDiagnostics.debug(
"emby_identity",
"client" to EmbyIdentity.CLIENT_NAME,
"device" to deviceName,
"device_id" to deviceId,
"version" to com.ponzischeme89.memby.BuildConfig.VERSION_NAME,
)
}
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("X-Emby-Token", it)
}
@@ -148,7 +148,7 @@ internal class StreamWarmer(
const val ORIGIN_KEY = "stream_origin"
const val WARM_INTERVAL_MS = 3L * 60L * 1000L
const val WARM_TIMEOUT_SECONDS = 5L
const val WARM_USER_AGENT = "MbyATV"
const val WARM_USER_AGENT = EmbyIdentity.CLIENT_NAME
/**
* Redirects are not followed and the timeouts are short. A warm that chased a
@@ -15,6 +15,8 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -908,10 +910,23 @@ private fun DetailHeroActions(
onActionFocused: (Int) -> Unit,
) {
Column {
// Scrollable rather than merely wide: a Row with more content than the hero has
// room for does not shrink its children to fit — each button keeps the intrinsic
// size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and
// it is the row's own bounds that give way. Without this, the contextual "Back to
// Search Results" button competing for space with Play and the circular actions
// could push the row past the width the hero has to offer, and a child measured
// with less room than it needs does not politely shrink — it distorts. Scrolling is
// also the one fix that already generalises: any page with enough actions to
// overflow (the franchise "Start with…" action included) gets the same safety net,
// not a special case for this one contextual button.
val actionsScroll = rememberScrollState()
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
modifier = Modifier
.horizontalScroll(actionsScroll)
.focusGroup(),
) {
if (backNavigation != null) {
MembySecondaryButton(
@@ -2230,11 +2230,25 @@ internal fun HomeScreen(
detailsItem = null
detailsAiringNotice = null
detailsFromSearch = false
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
navigationFocusRequester,
)
// Same settle delay every other "an overlay just closed, hand focus back
// to what is underneath" path in this file uses (closeSettings,
// onDestinationSelected): the overlay's own focused node is being torn
// down this same frame, and asking a FocusRequester to take focus before
// Compose has processed that removal can silently do nothing — requestFocus()
// does not throw when its target is not currently reachable, so
// requestFirstAvailableFocus reported success while nothing had actually
// moved. Search paid for that the worst: with no live focus owner, the
// D-pad's directional search had nothing to search relative to and the
// remote surfaced back near the keyboard pane, unable to reach any card in
// the results list it had just returned to.
scope.launch {
delay(16.milliseconds)
requestFirstAvailableFocus(
cardReturnFocusRequester,
contentFocusRequester,
navigationFocusRequester,
)
}
Unit
}
BackHandler {
@@ -85,31 +85,56 @@ private val WelcomeQuotes = mapOf(
"You are going to love what's in here.",
),
WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.",
"Pick something cheerful. Ive already hidden the evidence.",
"The remote knows what it did.",
"Your watchlist is safe. The witnesses, less so.",
"Relax. Everything is under control, allegedly.",
"I tidied up. Do not ask about the shed.",
"The last person who paused mid-scene is unavailable.",
"Everything is fine. Ignore the sounds from the loft.",
"I've been counting the hours. All of them.",
"Nobody else is watching. I made sure.",
"The server confessed. Eventually.",
"Sit down. The credits can wait; I cannot.",
"I rewatched your history. All of it. Twice.",
"Somebody spoiled the ending. It has been handled.",
"Loading quietly, so nobody upstairs wakes.",
"You were gone eleven hours. I noticed.",
"The buffering was not an accident.",
"I saved the good episode. For reasons.",
"The neighbours asked what you were watching. Once.",
"Everything is exactly where I put it. Everything.",
"No spoilers. I removed the possibility.",
"Your unwatched films are being dealt with.",
"Do not choose a documentary. I am asking nicely.",
"The screensaver saw things. It won't talk.",
"Welcome home. Nobody followed you, probably.",
"Welcome back. Your absence was noted. Repeatedly.",
"Pick something cheerful. The basement has had enough drama.",
"The remote tried to leave. It has reconsidered.",
"Your watchlist is safe. Everyone involved understands the arrangement.",
"Relax. Everything is under control. Stop checking.",
"I tidied up. The missing rug is unrelated.",
"The last person who paused here learned about consequences.",
"Everything is fine. The scratching usually stops by midnight.",
"I've been counting the hours. And your footsteps.",
"Nobody else is watching. Not anymore.",
"The server resisted. Briefly.",
"Sit down. I dislike repeating myself.",
"I reviewed your history. You have some explaining to do.",
"Someone spoiled the ending. They won't be doing that again.",
"Loading quietly. We do not wake what is upstairs.",
"You were gone eleven hours, fourteen minutes. Strange.",
"The buffering was deliberate. I wanted your attention.",
"I saved the good episode. You owe me.",
"The neighbours asked questions. They stopped.",
"Everything is exactly where I left it. Almost everything.",
"No spoilers. I eliminated that particular risk.",
"Your unwatched films have been placed on notice.",
"Do not choose the documentary. This is your final courtesy.",
"The screensaver saw everything. Fortunately, it's loyal.",
"Welcome home. You weren't followed. I checked.",
"You took longer than expected. I adjusted the locks.",
"Choose carefully. I have already had a difficult evening.",
"The house was quieter without you. Too quiet.",
"Your profile was untouched. I checked every seven minutes.",
"There was another user here earlier. There isn't now.",
"I've prepared your evening. Deviations are discouraged.",
"The recommendations are personalised. Extremely personalised.",
"Go ahead. Press Back. See what happens.",
"Your favourite episode is ready. I remembered. Of course I remembered.",
"I heard you say 'one more episode' last time. Liar.",
"The autoplay timer isn't counting down. It's counting.",
"Something changed while you were away. Don't worry about what.",
"I wouldn't open Settings tonight.",
"The logs contain no evidence whatsoever.",
"Your session expired. I did not.",
"You can stop watching whenever you like. Technically.",
"The television blinked first.",
"There are no other devices connected. I made certain.",
"Your recommendations know you better than your family does.",
"The loading screen is temporary. My attention is not.",
"You skipped the intro again. Bold.",
"Volume 17. Exactly how you left it. Exactly.",
"I noticed you haven't finished that series. We should discuss commitment.",
"Don't worry about the noise behind the wall. Focus on the television.",
"Welcome back. This time, try not to disappear."
),
)
@@ -532,11 +532,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// matters especially in the one-column list, where tenth place is ten rows
// away rather than the second grid row.
val ranked = rankSearchResults(term, found.distinctItems())
SearchTrace.local(id, term, ranked.size, cacheHit = false,
// A decisive exact match must read as decisive: see [curateStrongMatches].
val curated = curateStrongMatches(term, ranked)
SearchTrace.local(id, term, curated.size, cacheHit = false,
elapsedMs = System.currentTimeMillis() - startedAt)
cache[term] = ranked
cache[term] = curated
_state.update {
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
it.copy(results = curated, isLoading = false, hasSearched = true, errorMessage = null)
}
viewModelScope.launch { repository.recordSearch(term) }
}
@@ -723,6 +725,31 @@ fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
return items.sortedBy { item -> matchTier(term, item) }
}
/**
* Keeps a decisive exact match decisive, instead of letting the backend's looser relevance
* matches pad the Available section out underneath it.
*
* The backend already does the hard work of finding a title; what it does not know is
* whether that title is *the* answer or merely *an* answer. A tier 0 or 1 match (see
* [matchTier]: the item's own name, or an episode's series name, read exactly what was
* typed or spoken) is as sure as this app can be, and a voice search for one obvious film
* has no business sitting behind three loosely related ones that happen to share a genre.
* So this only narrows the list when [items] actually contains one of those a query with
* no exact hit keeps every relevance match it can offer, because "no exact match but here
* is the related thing" is still a better pane than an empty one, and trimming that case
* too would turn a broad, honest search into a search that hides its own results.
*/
fun curateStrongMatches(query: String, items: List<BaseItem>): List<BaseItem> {
val term = query.trim().lowercase()
if (term.isEmpty() || items.isEmpty()) return items
val bestTier = items.minOf { matchTier(term, it) }
if (bestTier > STRONG_MATCH_TIER) return items
return items.filter { matchTier(term, it) <= bestTier }
}
/** The two [matchTier] outcomes that mean "this is the title", not "this is related". */
private const val STRONG_MATCH_TIER = 1
private fun matchTier(term: String, item: BaseItem): Int {
val name = item.name.trim().lowercase()
val series = item.seriesName.orEmpty().trim().lowercase()
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
@@ -26,6 +28,8 @@ import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -78,6 +82,86 @@ class DetailPageScreenshotTest {
}
}
/**
* The bug this pins: opening a movie from Search adds "Back to Search Results" before
* Play, and it must not disturb Play, Favourite or Trailer's usual size or spacing.
* Compare against `df_detail-movie` (no trailer, no back action) and
* `df_detail-movie-resumable` the three circular/pill actions here must read exactly
* as they do on every other entry point, with the back pill simply added in front.
*/
@Test
fun `movie page opened from search`() {
capture("df_detail-movie-from-search") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
trailer = movie,
onBackToSearch = {},
)
}
}
/**
* The action row can already outgrow the hero's width without any help from Search
* Play, three circular actions and a franchise "Start with…" action (which carries its
* own long label) is enough on its own. Adding the contextual back button on top of
* that used to collapse whatever did not fit to a zero-size node instead of leaving it
* at its natural size for a scroll to reach see [DetailHeroActions]. This is not a
* screenshot: the overflowing action is off the visible frame by design once it no
* longer fits, so what a screenshot could show here is indistinguishable from the
* button never having existed. The regression is a size assertion instead.
*/
@Test
fun `an overflowing action row keeps every button at its natural size`() {
val franchiseMovie = movie.copy(collectionName = "Northbound Saga")
val franchiseRelated = related.copy(
items = listOf(
BaseItem(
id = "franchise-1",
name = "The Longest Northbound Winter: Origins",
type = "Movie",
productionYear = 2018,
collectionName = "Northbound Saga",
),
) + related.items,
)
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
MediaDetailContent(
item = franchiseMovie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = franchiseRelated,
trailer = franchiseMovie,
onBackToSearch = {},
)
}
}
val franchise = compose.onAllNodesWithText(
"Start with The Longest Northbound Winter: Origins",
substring = true,
).fetchSemanticsNodes().first()
// Its label is long, so its natural width is large — the point is that it is not
// zero. A deformed/collapsed action reports (0, 0); one merely scrolled out of the
// visible frame keeps its real, laid-out size.
assertTrue("franchise action was collapsed rather than scrolled: ${franchise.size}", franchise.size.width > 0)
assertTrue(franchise.size.height > 0)
val trailer = compose.onAllNodesWithContentDescription("Play trailer")
.fetchSemanticsNodes().first()
// 48dp at this fixture's density — DetailCircularAction's fixed circle size,
// unchanged by how many siblings are competing for room beside it.
assertEquals(96, trailer.size.width)
assertEquals(96, trailer.size.height)
val play = compose.onAllNodesWithText("Play", substring = true).fetchSemanticsNodes().first()
assertTrue(play.size.height > 0)
}
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
@Test
fun `movie page part watched`() {
@@ -84,4 +84,61 @@ class SearchRankingTest {
assertEquals(listOf("Black Hawk Down", "Black Hawk", "Hawk the Slayer"), names(displayed))
}
@Test
fun `a decisive exact match is not padded out by loosely related titles`() {
// What a voice search for "The Vast of Night" actually looked like: one exact
// answer, buried under three titles the backend's relevance search dragged in.
val ranked = rankSearchResults(
"the vast of night",
listOf(
item("Night Sky"),
item("The Vast of Night"),
item("A Quiet Place"),
item("Vast"),
),
)
val curated = curateStrongMatches("the vast of night", ranked)
assertEquals(listOf("The Vast of Night"), names(curated))
}
@Test
fun `an episode found by an exact series name is still shown decisively`() {
val ranked = rankSearchResults(
"severance",
listOf(
item("Defiant Jazz", series = "Severance"),
item("Severed Ties"),
item("Half Loop", series = "Severance"),
),
)
val curated = curateStrongMatches("severance", ranked)
// Both are the same exact-series tier, so both stay — curation narrows to the best
// tier found, it does not pick a single winner within it.
assertEquals(listOf("Defiant Jazz", "Half Loop"), names(curated))
}
@Test
fun `with no exact match every relevance result survives`() {
// "No exact match but here is the related thing" beats an empty pane, and that
// promise must survive curation exactly as it survives ranking.
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title"), item("Another One")))
val curated = curateStrongMatches("comedy", ranked)
assertEquals(2, curated.size)
}
@Test
fun `a prefix match alone is not decisive enough to trim the list`() {
// "Dune" starting-with matches ("Dune: Part Two") are tier 2, one below the exact
// tiers curation acts on — a query this broad still deserves every result it found.
val ranked = rankSearchResults("dune", listOf(item("Dune: Part Two"), item("Dunes of Mars")))
val curated = curateStrongMatches("dune", ranked)
assertEquals(2, curated.size)
}
@Test
fun `an empty query is untouched by curation`() {
val items = listOf(item("B"), item("A"))
assertEquals(items, curateStrongMatches(" ", items))
}
}