0.3.31 - Studio logo to station indent
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
|||||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||||
|
|
||||||
val defaultVersionName = "0.3.29"
|
val defaultVersionName = "0.3.31"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.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.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||||
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
|
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.PlaybackInfoRequest
|
||||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||||
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
|
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
|
||||||
@@ -172,6 +173,14 @@ data class Playable(
|
|||||||
* to lose the credits pane with it.
|
* to lose the credits pane with it.
|
||||||
*/
|
*/
|
||||||
val endCreditsAvailable: Boolean = false,
|
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 overview: String? = null,
|
||||||
val episodeCode: String? = null,
|
val episodeCode: String? = null,
|
||||||
val runtimeMs: Long = 0L,
|
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,
|
overview = item.overview,
|
||||||
episodeCode = episodeCode(item),
|
episodeCode = episodeCode(item),
|
||||||
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||||
|
streamingService = item.streamingService,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the request used by a real Play action.
|
* Builds the request used by a real Play action.
|
||||||
*
|
*
|
||||||
* Episode rows are intentionally lightweight and some entry points can therefore omit
|
* 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
|
* the parent-logo identity, or the Studios field [BaseItem.streamingService] reads.
|
||||||
* player opens, so the immediate and server-resolved launch paths receive the same title
|
* Resolve both from the associated series before the player opens, so the immediate
|
||||||
* artwork regardless of which row supplied the episode.
|
* 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 {
|
suspend fun playbackRequestForLaunch(item: BaseItem): PlaybackRequest {
|
||||||
val request = playbackRequest(item)
|
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 seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: return request
|
||||||
val seriesLogo = try {
|
val series = try {
|
||||||
getItemDetails(seriesId)?.let(::logoUrl)
|
getItemDetails(seriesId)
|
||||||
} catch (error: CancellationException) {
|
} catch (error: CancellationException) {
|
||||||
throw error
|
throw error
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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,
|
trickplayAvailable = playback.trickplayAvailable,
|
||||||
skipIntroAvailable = playback.skipIntroAvailable,
|
skipIntroAvailable = playback.skipIntroAvailable,
|
||||||
endCreditsAvailable = playback.endCreditsAvailable,
|
endCreditsAvailable = playback.endCreditsAvailable,
|
||||||
|
streamingService = item.streamingService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (item.isSeries) {
|
if (item.isSeries) {
|
||||||
@@ -2568,6 +2592,7 @@ class EmbyRepository internal constructor(
|
|||||||
overview = episode.overview,
|
overview = episode.overview,
|
||||||
episodeCode = episodeCode(episode),
|
episodeCode = episodeCode(episode),
|
||||||
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||||
|
streamingService = item.streamingService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val discovery = directPlayback(
|
val discovery = directPlayback(
|
||||||
@@ -2594,6 +2619,7 @@ class EmbyRepository internal constructor(
|
|||||||
overview = item.overview,
|
overview = item.overview,
|
||||||
episodeCode = item.episodeCode,
|
episodeCode = item.episodeCode,
|
||||||
runtimeMs = item.runtimeMs,
|
runtimeMs = item.runtimeMs,
|
||||||
|
streamingService = item.streamingService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,45 @@ data class Studio(
|
|||||||
@SerialName("Name") val name: String = "",
|
@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
|
@Serializable
|
||||||
data class MediaStream(
|
data class MediaStream(
|
||||||
@SerialName("Index") val index: Int = -1,
|
@SerialName("Index") val index: Int = -1,
|
||||||
@@ -503,6 +542,20 @@ data class BaseItem(
|
|||||||
val isMovieSchedule: Boolean get() = membySource == "radarr"
|
val isMovieSchedule: Boolean get() = membySource == "radarr"
|
||||||
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
|
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
|
* 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
|
* 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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
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
|
// Down out of the strip lands on the top genre, in whatever
|
||||||
// order personalisation put it in — never a fixed one.
|
// order personalisation put it in — never a fixed one.
|
||||||
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
|
downFocusRequester = railFocusRequesters[state.categories.firstOrNull()?.id],
|
||||||
|
onEnterContent = ::enterGrid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -771,6 +773,14 @@ internal fun ServicesRail(
|
|||||||
selectedServiceId: String? = null,
|
selectedServiceId: String? = null,
|
||||||
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
|
/** The [GenreRailItem] precedent: lets a screenshot render one icon as though focused. */
|
||||||
focusForCapture: String? = null,
|
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 }) {
|
val ownedFocusRequesters = remember(services.map { it.id }) {
|
||||||
services.associate { it.id to FocusRequester() }
|
services.associate { it.id to FocusRequester() }
|
||||||
@@ -778,6 +788,18 @@ internal fun ServicesRail(
|
|||||||
val requesterFor: (String) -> FocusRequester = { id ->
|
val requesterFor: (String) -> FocusRequester = { id ->
|
||||||
itemFocusRequesters[id] ?: ownedFocusRequesters.getValue(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()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
"SERVICES",
|
"SERVICES",
|
||||||
@@ -787,14 +809,19 @@ internal fun ServicesRail(
|
|||||||
letterSpacing = 1.6.sp,
|
letterSpacing = 1.6.sp,
|
||||||
modifier = Modifier.padding(start = 22.dp, top = 22.dp, bottom = 14.dp),
|
modifier = Modifier.padding(start = 22.dp, top = 22.dp, bottom = 14.dp),
|
||||||
)
|
)
|
||||||
Row(
|
LazyRow(
|
||||||
Modifier
|
state = stripState,
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
services.forEachIndexed { index, service ->
|
rowItemsIndexed(services, key = { _, service -> service.id }) { index, service ->
|
||||||
val requester = requesterFor(service.id)
|
val requester = requesterFor(service.id)
|
||||||
ServiceIconButton(
|
ServiceIconButton(
|
||||||
service = service,
|
service = service,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.ponzischeme89.memby.ui.genre
|
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.BaseItem
|
||||||
|
import com.ponzischeme89.memby.data.model.NetflixStudioNames
|
||||||
import com.ponzischeme89.memby.data.model.UserItemData
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,14 +187,14 @@ private val coreServiceCategories = listOf(
|
|||||||
GenreCategory(
|
GenreCategory(
|
||||||
"service-apple-tv-plus",
|
"service-apple-tv-plus",
|
||||||
"Apple TV+",
|
"Apple TV+",
|
||||||
listOf("Apple TV+", "Apple TV Plus", "AppleTV+"),
|
AppleTvPlusStudioNames,
|
||||||
GenreCategoryIcon.APPLE_TV,
|
GenreCategoryIcon.APPLE_TV,
|
||||||
BrowseFilterKind.STUDIO,
|
BrowseFilterKind.STUDIO,
|
||||||
),
|
),
|
||||||
GenreCategory(
|
GenreCategory(
|
||||||
"service-netflix",
|
"service-netflix",
|
||||||
"Netflix",
|
"Netflix",
|
||||||
listOf("Netflix"),
|
NetflixStudioNames,
|
||||||
GenreCategoryIcon.NETFLIX,
|
GenreCategoryIcon.NETFLIX,
|
||||||
BrowseFilterKind.STUDIO,
|
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.GatewayPrerollSchedule
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
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.model.TrailerPlaybackRequest
|
||||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
|
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
|
||||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||||
@@ -254,6 +255,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var nowPlayingGroup: View? = null
|
private var nowPlayingGroup: View? = null
|
||||||
private var playbackIdentityView: View? = null
|
private var playbackIdentityView: View? = null
|
||||||
private var playbackIdentityHideJob: Job? = 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
|
* 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)
|
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
|
||||||
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
||||||
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
|
?: 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
|
// 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.
|
// of what it has already put in front of this viewer.
|
||||||
savedInstanceState?.getStringArrayList(STATE_MAGIC_OFFERED)?.let {
|
savedInstanceState?.getStringArrayList(STATE_MAGIC_OFFERED)?.let {
|
||||||
@@ -1335,6 +1347,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
trickplayAvailable = playable.trickplayAvailable
|
trickplayAvailable = playable.trickplayAvailable
|
||||||
skipIntroAvailable = playable.skipIntroAvailable
|
skipIntroAvailable = playable.skipIntroAvailable
|
||||||
endCreditsAvailable = playable.endCreditsAvailable
|
endCreditsAvailable = playable.endCreditsAvailable
|
||||||
|
streamingService = playable.streamingService
|
||||||
subtitleAutoSelectionAttempted = false
|
subtitleAutoSelectionAttempted = false
|
||||||
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
|
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
|
||||||
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
|
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
|
||||||
@@ -2318,6 +2331,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
logoUrl: String?,
|
logoUrl: String?,
|
||||||
) {
|
) {
|
||||||
playbackIdentityView = findViewById(R.id.player_playback_identity)
|
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 logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
|
||||||
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
|
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
|
||||||
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
|
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
|
||||||
@@ -2366,9 +2380,51 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
alpha = 0f
|
alpha = 0f
|
||||||
visibility = View.GONE
|
visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
playbackServiceBadge?.apply {
|
||||||
|
animate().cancel()
|
||||||
|
alpha = 0f
|
||||||
|
visibility = View.GONE
|
||||||
|
}
|
||||||
applyIdentityRegion()
|
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.
|
* Raises the ident, once, if the corner is actually free.
|
||||||
*
|
*
|
||||||
@@ -2407,6 +2463,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
.alpha(1f)
|
.alpha(1f)
|
||||||
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
|
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
|
||||||
.start()
|
.start()
|
||||||
|
fadeServiceBadge(visible = true)
|
||||||
MembyDiagnostics.info(
|
MembyDiagnostics.info(
|
||||||
"station_ident_shown",
|
"station_ident_shown",
|
||||||
"playback" to playSessionId,
|
"playback" to playSessionId,
|
||||||
@@ -2447,6 +2504,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
.withEndAction { visibility = View.GONE }
|
.withEndAction { visibility = View.GONE }
|
||||||
.start()
|
.start()
|
||||||
}
|
}
|
||||||
|
fadeServiceBadge(visible = false)
|
||||||
applyIdentityRegion()
|
applyIdentityRegion()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5556,6 +5614,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
|
outState.putBoolean(STATE_TRICKPLAY, trickplayAvailable)
|
||||||
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
|
outState.putBoolean(STATE_SKIP_INTRO, skipIntroAvailable)
|
||||||
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
|
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_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
|
||||||
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
|
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
|
||||||
outState.putString(
|
outState.putString(
|
||||||
@@ -5906,6 +5965,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
|
private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
|
||||||
private const val EXTRA_SKIP_INTRO = "extra_skip_intro_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_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_MEDIA_SOURCE_ID = "extra_media_source_id"
|
||||||
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
||||||
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
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_TRICKPLAY = "state_trickplay"
|
||||||
private const val STATE_SKIP_INTRO = "state_skip_intro"
|
private const val STATE_SKIP_INTRO = "state_skip_intro"
|
||||||
private const val STATE_END_CREDITS = "state_end_credits"
|
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_TITLE = "state_title"
|
||||||
private const val STATE_LOGO_URL = "state_logo_url"
|
private const val STATE_LOGO_URL = "state_logo_url"
|
||||||
private const val STATE_BACKDROP_URL = "state_backdrop_url"
|
private const val STATE_BACKDROP_URL = "state_backdrop_url"
|
||||||
@@ -6043,6 +6104,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
trickplayAvailable = playable.trickplayAvailable,
|
trickplayAvailable = playable.trickplayAvailable,
|
||||||
skipIntroAvailable = playable.skipIntroAvailable,
|
skipIntroAvailable = playable.skipIntroAvailable,
|
||||||
endCreditsAvailable = playable.endCreditsAvailable,
|
endCreditsAvailable = playable.endCreditsAvailable,
|
||||||
|
streamingService = playable.streamingService,
|
||||||
mediaSourceId = playable.mediaSourceId,
|
mediaSourceId = playable.mediaSourceId,
|
||||||
playSessionId = playable.playSessionId,
|
playSessionId = playable.playSessionId,
|
||||||
playMethod = playable.playMethod,
|
playMethod = playable.playMethod,
|
||||||
@@ -6072,6 +6134,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
trickplayAvailable: Boolean = false,
|
trickplayAvailable: Boolean = false,
|
||||||
skipIntroAvailable: Boolean = false,
|
skipIntroAvailable: Boolean = false,
|
||||||
endCreditsAvailable: Boolean = false,
|
endCreditsAvailable: Boolean = false,
|
||||||
|
streamingService: StreamingService? = null,
|
||||||
mediaSourceId: String = "",
|
mediaSourceId: String = "",
|
||||||
playSessionId: String = "",
|
playSessionId: String = "",
|
||||||
playMethod: String = "DirectPlay",
|
playMethod: String = "DirectPlay",
|
||||||
@@ -6100,6 +6163,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
|
putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
|
||||||
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
|
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
|
||||||
putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
|
putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
|
||||||
|
streamingService?.let { putExtra(EXTRA_STREAMING_SERVICE, it.name) }
|
||||||
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
|
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
|
||||||
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
|
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
|
||||||
putExtra(EXTRA_PLAY_METHOD, playMethod)
|
putExtra(EXTRA_PLAY_METHOD, playMethod)
|
||||||
@@ -6119,6 +6183,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
runCatching { playerJson.decodeFromString<TrailerPlaybackRequest>(it) }.getOrNull()
|
runCatching { playerJson.decodeFromString<TrailerPlaybackRequest>(it) }.getOrNull()
|
||||||
}?.takeIf { it.subjectId.isNotBlank() }
|
}?.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 {
|
private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem {
|
||||||
val configurations = subtitles.filter {
|
val configurations = subtitles.filter {
|
||||||
it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank()
|
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. -->
|
controller, so it can appear briefly without opening the transport controls. -->
|
||||||
<include layout="@layout/player_playback_identity" />
|
<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
|
<!-- 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
|
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. -->
|
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" />
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="player_title_logo">Title logo</string>
|
<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">Starting playback…</string>
|
||||||
<string name="playback_loading_hint">Connecting directly to Emby</string>
|
<string name="playback_loading_hint">Connecting directly to Emby</string>
|
||||||
<string name="playback_reconnecting">Connection interrupted</string>
|
<string name="playback_reconnecting">Connection interrupted</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user