0.3.31 - Studio logo to station indent

This commit is contained in:
ponzischeme89
2026-08-26 12:02:12 +12:00
parent 263177a9e4
commit a5f9a91832
13 changed files with 226 additions and 15 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.29"
val defaultVersionName = "0.3.31"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -25,6 +25,7 @@ import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
import com.ponzischeme89.memby.data.model.StreamingService
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
@@ -172,6 +173,14 @@ data class Playable(
* to lose the credits pane with it.
*/
val endCreditsAvailable: Boolean = false,
/**
* Which streaming service, if any, this title is licensed through, by Emby's own
* Studios field — see [BaseItem.streamingService]. Drives the service mark on the
* station ident, in the corner opposite the title's own logo. Never asked of the
* backend: it rides whatever the launcher's card already knew, so it is missing rather
* than wrong wherever that card's fields did not include Studios.
*/
val streamingService: StreamingService? = null,
)
/**
@@ -237,6 +246,13 @@ data class PlaybackRequest(
val overview: String? = null,
val episodeCode: String? = null,
val runtimeMs: Long = 0L,
/**
* The streaming service the launcher's own copy of this item already named among its
* Studios, if any. Carried through rather than re-asked, the [logoUrl] precedent:
* nobody but the card the viewer pressed knows this at launch time, and no server call
* sits on the critical path to learn it. See [Playable.streamingService].
*/
val streamingService: StreamingService? = null,
)
/**
@@ -2214,28 +2230,35 @@ class EmbyRepository internal constructor(
overview = item.overview,
episodeCode = episodeCode(item),
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
streamingService = item.streamingService,
)
/**
* Builds the request used by a real Play action.
*
* Episode rows are intentionally lightweight and some entry points can therefore omit
* the parent-logo identity. Resolve that identity from the associated series before the
* player opens, so the immediate and server-resolved launch paths receive the same title
* artwork regardless of which row supplied the episode.
* the parent-logo identity, or the Studios field [BaseItem.streamingService] reads.
* Resolve both from the associated series before the player opens, so the immediate
* and server-resolved launch paths receive the same title artwork and service badge
* regardless of which row supplied the episode.
*/
suspend fun playbackRequestForLaunch(item: BaseItem): PlaybackRequest {
val request = playbackRequest(item)
if (!item.isEpisode || request.logoUrl != null) return request
val needsLogo = item.isEpisode && request.logoUrl == null
val needsStreamingService = item.isEpisode && request.streamingService == null
if (!needsLogo && !needsStreamingService) return request
val seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: return request
val seriesLogo = try {
getItemDetails(seriesId)?.let(::logoUrl)
val series = try {
getItemDetails(seriesId)
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
null
}
return request.copy(logoUrl = seriesLogo)
return request.copy(
logoUrl = if (needsLogo) series?.let(::logoUrl) else request.logoUrl,
streamingService = if (needsStreamingService) series?.streamingService else request.streamingService,
)
}
/**
@@ -2533,6 +2556,7 @@ class EmbyRepository internal constructor(
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
streamingService = item.streamingService,
)
}
if (item.isSeries) {
@@ -2568,6 +2592,7 @@ class EmbyRepository internal constructor(
overview = episode.overview,
episodeCode = episodeCode(episode),
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
streamingService = item.streamingService,
)
}
val discovery = directPlayback(
@@ -2594,6 +2619,7 @@ class EmbyRepository internal constructor(
overview = item.overview,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
streamingService = item.streamingService,
)
}
@@ -316,6 +316,45 @@ data class Studio(
@SerialName("Name") val name: String = "",
)
/**
* The Emby `Studios` spellings that mean Apple TV+, shared between [BaseItem.streamingService]
* and the Services shortcut strip's Apple TV+ category in `ui/genre/GenreCategories.kt` one
* vocabulary, so a title's playback badge and its place in that shelf can never disagree.
*
* Like the genre aliases in `GenreCategories.kt`, this is two vocabularies rather than one,
* and for the same reason: TMDb records a *network* for television ("Apple TV+") but a
* *production company* for film, and it does not use one name for that "Apple Studios"
* credits an Apple-produced film outright, "Apple Original Films" credits one distributed
* by Apple, and both show up on real libraries. Missing either meant the shelf found every
* Apple TV+ *series* and almost none of its films Emby's `Studios` filter is an exact
* match, so a spelling not listed here is a title that cannot be found.
*/
val AppleTvPlusStudioNames = listOf(
"Apple TV+",
"Apple TV Plus",
"AppleTV+",
"Apple Studios",
"Apple Original Films",
)
/**
* The Emby `Studios` spellings that mean Netflix, the [AppleTvPlusStudioNames] precedent
* shared between [BaseItem.streamingService] and the Services strip's Netflix category.
*/
val NetflixStudioNames = listOf("Netflix")
/**
* A streaming service a title is licensed through, as far as Emby's `Studios` field says so.
* Drives the station ident's service mark during playback (see [BaseItem.streamingService])
* and is entirely a client-side judgement never asked of the backend, so it is missing
* rather than wrong wherever a title's Studios were not fetched.
*/
@Serializable
enum class StreamingService {
APPLE_TV,
NETFLIX,
}
@Serializable
data class MediaStream(
@SerialName("Index") val index: Int = -1,
@@ -503,6 +542,20 @@ data class BaseItem(
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
/**
* Which streaming service, if any, Emby's [studios] names for this title the same
* spellings the Services shortcut strip in `ui/genre` filters on. Drives the streaming
* service mark on the playback station ident; a title Emby has not tagged this way
* simply carries no badge rather than a guess. Apple TV+ is checked first only because
* a title is never genuinely both the order is not a priority rule.
*/
val streamingService: StreamingService?
get() = when {
studios.any { it.name in AppleTvPlusStudioNames } -> StreamingService.APPLE_TV
studios.any { it.name in NetflixStudioNames } -> StreamingService.NETFLIX
else -> null
}
/**
* A film Radarr is tracking that Emby has no copy of, which is the one card in the app
* with a page of its own rather than an Emby one. The moment the library imports it the
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
@@ -304,6 +305,7 @@ fun GenreBrowseScreen(
// Down out of the strip lands on the top genre, in whatever
// order personalisation put it in — never a fixed one.
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
onEnterContent = ::enterGrid,
)
}
},
@@ -771,6 +773,14 @@ internal fun ServicesRail(
selectedServiceId: String? = null,
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
focusForCapture: String? = null,
/**
* The [GenreRail] `LazyColumn` precedent: Right belongs to this strip as a whole, and
* has to scroll the remembered card back into composition before anything can be
* focused, which a `focusProperties` target could not do. Without this, Right on a
* service icon fell through to default focus search, which landed on whichever genre
* happened to sit below it rather than entering the grid.
*/
onEnterContent: () -> Boolean = { false },
) {
val ownedFocusRequesters = remember(services.map { it.id }) {
services.associate { it.id to FocusRequester() }
@@ -778,6 +788,18 @@ internal fun ServicesRail(
val requesterFor: (String) -> FocusRequester = { id ->
itemFocusRequesters[id] ?: ownedFocusRequesters.getValue(id)
}
// The strip is narrower than four icons at this diameter, and a fifth is only a matter
// of time — the GenreRail precedent applies here too, only sideways. A plain Row does
// not clip, so an icon past the rail's width used to draw itself over the content pane
// rather than being reachable by scrolling to it.
val stripState = rememberLazyListState()
// Only on arrival: once the viewer is in the strip, LazyRow brings the focused icon
// into view on its own as the D-pad travels, and a second scroll chasing the selection
// would fight it.
LaunchedEffect(Unit) {
val index = services.indexOfFirst { it.id == selectedServiceId }
if (index > 0) runCatching { stripState.scrollToItem(index) }
}
Column(Modifier.fillMaxWidth()) {
Text(
"SERVICES",
@@ -787,14 +809,19 @@ internal fun ServicesRail(
letterSpacing = 1.6.sp,
modifier = Modifier.padding(start = 22.dp, top = 22.dp, bottom = 14.dp),
)
Row(
Modifier
LazyRow(
state = stripState,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 14.dp)
.focusGroup(),
.focusGroup()
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
if (event.key == Key.DirectionRight) onEnterContent() else false
},
contentPadding = PaddingValues(start = 16.dp, end = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
services.forEachIndexed { index, service ->
rowItemsIndexed(services, key = { _, service -> service.id }) { index, service ->
val requester = requesterFor(service.id)
ServiceIconButton(
service = service,
@@ -1,6 +1,8 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.data.model.AppleTvPlusStudioNames
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.NetflixStudioNames
import com.ponzischeme89.memby.data.model.UserItemData
/**
@@ -185,14 +187,14 @@ private val coreServiceCategories = listOf(
GenreCategory(
"service-apple-tv-plus",
"Apple TV+",
listOf("Apple TV+", "Apple TV Plus", "AppleTV+"),
AppleTvPlusStudioNames,
GenreCategoryIcon.APPLE_TV,
BrowseFilterKind.STUDIO,
),
GenreCategory(
"service-netflix",
"Netflix",
listOf("Netflix"),
NetflixStudioNames,
GenreCategoryIcon.NETFLIX,
BrowseFilterKind.STUDIO,
),
@@ -96,6 +96,7 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.StreamingService
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
@@ -254,6 +255,10 @@ class PlayerActivity : ComponentActivity() {
private var nowPlayingGroup: View? = null
private var playbackIdentityView: View? = null
private var playbackIdentityHideJob: Job? = null
/** The streaming-service mark, top-end — raised and cleared alongside [playbackIdentityView]. */
private var playbackServiceBadge: ImageView? = null
/** Which service the title now playing is licensed through, if any — see [Playable.streamingService]. */
private var streamingService: StreamingService? = null
/**
* The ident is one-shot per programme, so its phase never a boolean is what decides
@@ -658,6 +663,13 @@ class PlayerActivity : ComponentActivity() {
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
// Same rule and default: a missing extra must never draw a badge for a title Emby
// never told this build was licensed through a service. On the request form this
// is corrected by adoptPlayable once the server settles.
streamingService = decodeStreamingService(
savedInstanceState?.getString(STATE_STREAMING_SERVICE)
?: intent.getStringExtra(EXTRA_STREAMING_SERVICE),
)
// Carried across the recreate a Magic press causes, so the button keeps its memory
// of what it has already put in front of this viewer.
savedInstanceState?.getStringArrayList(STATE_MAGIC_OFFERED)?.let {
@@ -1335,6 +1347,7 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable
endCreditsAvailable = playable.endCreditsAvailable
streamingService = playable.streamingService
subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
@@ -2318,6 +2331,7 @@ class PlayerActivity : ComponentActivity() {
logoUrl: String?,
) {
playbackIdentityView = findViewById(R.id.player_playback_identity)
playbackServiceBadge = findViewById(R.id.player_playback_service_badge)
val logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
@@ -2366,9 +2380,51 @@ class PlayerActivity : ComponentActivity() {
alpha = 0f
visibility = View.GONE
}
playbackServiceBadge?.apply {
animate().cancel()
alpha = 0f
visibility = View.GONE
}
applyIdentityRegion()
}
/**
* Fades [playbackServiceBadge] in step with the ident it shares a window with never
* on its own timer, so it can never linger after the ident's turn is over or appear
* ahead of it. Withheld when [streamingService] is null: most titles earn no badge at
* all, and showing an empty corner is not the same as showing nothing.
*/
private fun fadeServiceBadge(visible: Boolean) {
val badge = playbackServiceBadge ?: return
badge.animate().cancel()
val service = streamingService
if (visible && service != null) {
badge.setImageResource(streamingServiceBadgeDrawableRes(service))
badge.contentDescription = getString(streamingServiceBadgeDescriptionRes(service))
badge.alpha = 0f
badge.visibility = View.VISIBLE
badge.animate().alpha(1f).setDuration(PLAYBACK_IDENTITY_FADE_MS).start()
} else {
badge.animate()
.alpha(0f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
.withEndAction { badge.visibility = View.GONE }
.start()
}
}
/** Which mark [fadeServiceBadge] draws for a given [StreamingService]. */
private fun streamingServiceBadgeDrawableRes(service: StreamingService): Int = when (service) {
StreamingService.APPLE_TV -> R.drawable.ic_playback_apple_tv
StreamingService.NETFLIX -> R.drawable.ic_playback_netflix
}
/** The content description that goes with [streamingServiceBadgeDrawableRes]. */
private fun streamingServiceBadgeDescriptionRes(service: StreamingService): Int = when (service) {
StreamingService.APPLE_TV -> R.string.player_apple_tv_badge
StreamingService.NETFLIX -> R.string.player_netflix_badge
}
/**
* Raises the ident, once, if the corner is actually free.
*
@@ -2407,6 +2463,7 @@ class PlayerActivity : ComponentActivity() {
.alpha(1f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
.start()
fadeServiceBadge(visible = true)
MembyDiagnostics.info(
"station_ident_shown",
"playback" to playSessionId,
@@ -2447,6 +2504,7 @@ class PlayerActivity : ComponentActivity() {
.withEndAction { visibility = View.GONE }
.start()
}
fadeServiceBadge(visible = false)
applyIdentityRegion()
}
@@ -5556,6 +5614,7 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
streamingService?.let { outState.putString(STATE_STREAMING_SERVICE, it.name) }
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(
@@ -5906,6 +5965,7 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available"
private const val EXTRA_END_CREDITS = "extra_end_credits_available"
private const val EXTRA_STREAMING_SERVICE = "extra_streaming_service"
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
private const val EXTRA_PLAY_METHOD = "extra_play_method"
@@ -5933,6 +5993,7 @@ class PlayerActivity : ComponentActivity() {
private const val STATE_TRICKPLAY = "state_trickplay"
private const val STATE_SKIP_INTRO = "state_skip_intro"
private const val STATE_END_CREDITS = "state_end_credits"
private const val STATE_STREAMING_SERVICE = "state_streaming_service"
private const val STATE_TITLE = "state_title"
private const val STATE_LOGO_URL = "state_logo_url"
private const val STATE_BACKDROP_URL = "state_backdrop_url"
@@ -6043,6 +6104,7 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable,
streamingService = playable.streamingService,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
@@ -6072,6 +6134,7 @@ class PlayerActivity : ComponentActivity() {
trickplayAvailable: Boolean = false,
skipIntroAvailable: Boolean = false,
endCreditsAvailable: Boolean = false,
streamingService: StreamingService? = null,
mediaSourceId: String = "",
playSessionId: String = "",
playMethod: String = "DirectPlay",
@@ -6100,6 +6163,7 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
streamingService?.let { putExtra(EXTRA_STREAMING_SERVICE, it.name) }
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
putExtra(EXTRA_PLAY_METHOD, playMethod)
@@ -6119,6 +6183,10 @@ class PlayerActivity : ComponentActivity() {
runCatching { playerJson.decodeFromString<TrailerPlaybackRequest>(it) }.getOrNull()
}?.takeIf { it.subjectId.isNotBlank() }
/** An unrecognised name is a build newer than this one's vocabulary, not a fault. */
private fun decodeStreamingService(raw: String?): StreamingService? =
raw?.let { runCatching { StreamingService.valueOf(it) }.getOrNull() }
private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem {
val configurations = subtitles.filter {
it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank()
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -22,6 +22,10 @@
controller, so it can appear briefly without opening the transport controls. -->
<include layout="@layout/player_playback_identity" />
<!-- The streaming-service mark, top-end — the ident's opposite corner and its same
window. See player_playback_service_badge.xml. -->
<include layout="@layout/player_playback_service_badge" />
<!-- Skipping. Declared before the timing cues so those sit above it: a lower third on
the left and this centred chip do not overlap, and if they ever do, the cue that
appears once matters more than one that appears on every press. -->
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The streaming-service mark for the title now playing — Apple TV+ or Netflix today, by
Emby's own Studios field (BaseItem.streamingService). It shares the station ident's
window: raised and cleared together with player_playback_identity, in the opposite
top-end corner, so it never outlives the ident's turn or appears once the transport or
the pause hero has taken the identity over. See PlaybackIdentity.kt and
setUpPlaybackIdentity / showPlaybackIdentity / dismissPlaybackIdentity in PlayerActivity.
The image itself (src, contentDescription) is set at runtime by fadeServiceBadge, which
is what picks the right mark for whichever service the title belongs to. Width and
height are both bounded rather than one fixed and the other free: Apple's mark is a wide
wordmark and Netflix's is a tall glyph, and a box that only bounded height would let the
tall one balloon wider than the corner has room for. -->
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_service_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_marginTop="34dp"
android:layout_marginEnd="48dp"
android:adjustViewBounds="true"
android:alpha="0"
android:contentDescription="@string/player_apple_tv_badge"
android:focusable="false"
android:maxWidth="120dp"
android:maxHeight="44dp"
android:scaleType="fitEnd"
android:src="@drawable/ic_playback_apple_tv"
android:visibility="gone" />
+2
View File
@@ -1,5 +1,7 @@
<resources>
<string name="player_title_logo">Title logo</string>
<string name="player_apple_tv_badge">Apple TV+ original</string>
<string name="player_netflix_badge">Netflix original</string>
<string name="playback_loading">Starting playback…</string>
<string name="playback_loading_hint">Connecting directly to Emby</string>
<string name="playback_reconnecting">Connection interrupted</string>