diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9c18e2d..275890c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 11e8747..8353c85 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index d07dbbd..db7d506 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 5f126b9..3f968fd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt index 06cd487..f5cf927 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt @@ -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, ), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index fe63f87..c7a7b75 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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(R.id.player_playback_identity_logo) val fallback = findViewById(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(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): MediaItem { val configurations = subtitles.filter { it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank() diff --git a/app/src/main/res/drawable-nodpi/ic_playback_apple_tv.png b/app/src/main/res/drawable-nodpi/ic_playback_apple_tv.png new file mode 100644 index 0000000..1b37954 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_playback_apple_tv.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_playback_netflix.png b/app/src/main/res/drawable-nodpi/ic_playback_netflix.png new file mode 100644 index 0000000..ca0bd24 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_playback_netflix.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png b/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png index c93da20..e71e39f 100644 Binary files a/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png and b/app/src/main/res/drawable-nodpi/ic_service_apple_tv.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_service_netflix.png b/app/src/main/res/drawable-nodpi/ic_service_netflix.png index 12f61c1..0a97680 100644 Binary files a/app/src/main/res/drawable-nodpi/ic_service_netflix.png and b/app/src/main/res/drawable-nodpi/ic_service_netflix.png differ diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index f8609ac..37dd94f 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -22,6 +22,10 @@ controller, so it can appear briefly without opening the transport controls. --> + + + diff --git a/app/src/main/res/layout/player_playback_service_badge.xml b/app/src/main/res/layout/player_playback_service_badge.xml new file mode 100644 index 0000000..3035736 --- /dev/null +++ b/app/src/main/res/layout/player_playback_service_badge.xml @@ -0,0 +1,29 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3bc3497..866334f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,7 @@ Title logo + Apple TV+ original + Netflix original Starting playback… Connecting directly to Emby Connection interrupted