From f8a84e34fdcecba0369155fc48f51c69a5b8c8b0 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Fri, 22 May 2026 09:43:04 +0200 Subject: [PATCH] Fix/tv interface android (#1576) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app/(auth)/player/direct-player.tsx | 12 +-- components/PlayButton.tv.tsx | 1 + components/home/Home.tv.tsx | 2 +- components/home/TVHeroCarousel.tsx | 10 ++- components/tv/TVPosterCard.tsx | 2 +- .../modules/mpvplayer/MPVLayerRenderer.kt | 14 +++- .../expo/modules/mpvplayer/MpvPlayerModule.kt | 12 +++ .../expo/modules/mpvplayer/MpvPlayerView.kt | 14 +++- .../TvRecommendationsPublisher.kt | 2 +- utils/tvDiscovery/payload.ts | 83 +++++++++++++------ 10 files changed, 115 insertions(+), 37 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 7caeba24..fe2fdd56 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -543,11 +543,6 @@ export default function page() { ], ); - /** Gets the initial playback position in seconds. */ - const _startPosition = useMemo(() => { - return ticksToSeconds(getInitialPlaybackTicks()); - }, [getInitialPlaybackTicks]); - /** Build video source config for MPV */ const videoSource = useMemo(() => { if (!stream?.url) return undefined; @@ -1104,6 +1099,13 @@ export default function page() { applySubtitleSettings(); }, [isVideoLoaded, settings]); + // Seek to resume position after file is loaded (MPV_EVENT_FILE_LOADED) + useEffect(() => { + if (!tracksReady || !videoRef.current) return; + const ticks = getInitialPlaybackTicks(); + videoRef.current?.seekTo?.(ticksToSeconds(ticks)); + }, [tracksReady, getInitialPlaybackTicks]); + // Apply initial playback speed when video loads useEffect(() => { if (!isVideoLoaded || !videoRef.current) return; diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index f79a3174..2486f424 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -69,6 +69,7 @@ export const PlayButton: React.FC = ({ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", + playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", }); const queryString = queryParams.toString(); diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index cffff54f..594580f3 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -46,7 +46,7 @@ import { updateTVDiscovery } from "@/utils/tvDiscovery/sync"; const HORIZONTAL_PADDING = scaleSize(60); const TOP_PADDING = scaleSize(100); // Generous gap between sections for Apple TV+ aesthetic -const SECTION_GAP = scaleSize(10); +const SECTION_GAP = scaleSize(24); type InfiniteScrollingCollectionListSection = { type: "InfiniteScrollingCollectionList"; diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 7a71fbfb..4e9e4712 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -207,7 +207,7 @@ export const TVHeroCarousel: React.FC = ({ const typography = useScaledTVTypography(); const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); - const _insets = useSafeAreaInsets(); + const insets = useSafeAreaInsets(); const router = useRouter(); // Active item for featured display (debounced) @@ -381,7 +381,13 @@ export const TVHeroCarousel: React.FC = ({ const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; return ( - + {/* Backdrop layers with crossfade */} = ({ position: "relative", width, aspectRatio, - borderRadius: scaleSize(4), + borderRadius: scaleSize(24), overflow: "hidden", backgroundColor: "#1a1a1a", borderWidth: scaleSize(2), diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt index 8860932e..a10fa80d 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MPVLayerRenderer.kt @@ -462,7 +462,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver { fun setSubtitleFontSize(size: Int) { MPVLib.setPropertyInt("sub-font-size", size) } - + + fun setSubtitleBorderStyle(style: String) { + MPVLib.setPropertyString("sub-border-style", style) + } + + fun setSubtitleBackgroundColor(color: String) { + MPVLib.setPropertyString("sub-back-color", color) + } + + fun setSubtitleAssOverride(mode: String) { + MPVLib.setPropertyString("sub-ass-override", mode) + } + // MARK: - Audio Track Controls fun getAudioTracks(): List> { diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 245c4ef5..3fa6d57f 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -151,6 +151,18 @@ class MpvPlayerModule : Module() { view.setSubtitleFontSize(size) } + AsyncFunction("setSubtitleBorderStyle") { view: MpvPlayerView, style: String -> + view.setSubtitleBorderStyle(style) + } + + AsyncFunction("setSubtitleBackgroundColor") { view: MpvPlayerView, color: String -> + view.setSubtitleBackgroundColor(color) + } + + AsyncFunction("setSubtitleAssOverride") { view: MpvPlayerView, mode: String -> + view.setSubtitleAssOverride(mode) + } + // Audio track functions AsyncFunction("getAudioTracks") { view: MpvPlayerView -> view.getAudioTracks() diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 5b8e2dd3..0fb6bdde 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -271,7 +271,19 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context fun setSubtitleFontSize(size: Int) { renderer?.setSubtitleFontSize(size) } - + + fun setSubtitleBorderStyle(style: String) { + renderer?.setSubtitleBorderStyle(style) + } + + fun setSubtitleBackgroundColor(color: String) { + renderer?.setSubtitleBackgroundColor(color) + } + + fun setSubtitleAssOverride(mode: String) { + renderer?.setSubtitleAssOverride(mode) + } + // MARK: - Audio Track Controls fun getAudioTracks(): List> { diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt index 349d89de..56c98ae9 100644 --- a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt +++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt @@ -243,6 +243,7 @@ internal object TvRecommendationsPublisher { .setContentId(providerId) .setIntentUri(buildIntentUri(context, item.optString("playRoute").ifBlank { item.optString("route") })) .setWeight(weight) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9) item.optString("subtitle").takeIf { it.isNotBlank() }?.let { builder.setDescription(it) @@ -250,7 +251,6 @@ internal object TvRecommendationsPublisher { imageUrl.takeIf { it.isNotBlank() }?.let { val imageUri = Uri.parse(it) - builder.setPosterArtUri(imageUri) builder.setThumbnailUri(imageUri) } diff --git a/utils/tvDiscovery/payload.ts b/utils/tvDiscovery/payload.ts index 6552c198..a9ac0fc7 100644 --- a/utils/tvDiscovery/payload.ts +++ b/utils/tvDiscovery/payload.ts @@ -25,30 +25,60 @@ export interface TVDiscoveryPayload { sections: TVDiscoverySection[]; } -function getTVDiscoveryImageUrl( +function getTVDiscoveryImage( item: BaseItemDto, api: Api, -): string | undefined { +): { url: string } | undefined { const baseUrl = api.basePath; - if (item.Type === "Episode") { - if (item.SeriesId && item.SeriesPrimaryImageTag) { - return `${baseUrl}/Items/${item.SeriesId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.SeriesPrimaryImageTag)}&width=500`; - } - - if (item.ParentPrimaryImageItemId && item.ParentPrimaryImageTag) { - return `${baseUrl}/Items/${item.ParentPrimaryImageItemId}/Images/Primary?quality=90&tag=${encodeURIComponent(item.ParentPrimaryImageTag)}&width=500`; - } + // 1. Episode backdrop + const episodeBackdrop = item.BackdropImageTags?.[0]; + if (item.Id && episodeBackdrop) { + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Backdrop/0` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(episodeBackdrop)}`, + }; } + // 2. Series backdrop + if (item.SeriesId) { + return { + url: + `${baseUrl}/Items/${item.SeriesId}/Images/Backdrop` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90`, + }; + } + + // 3. Generic item backdrop + const backdrop = item.BackdropImageTags?.[0]; + if (item.Id && backdrop) { + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Backdrop/0` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(backdrop)}`, + }; + } + + // 4. Last resort: crop poster into landscape const primaryTag = item.ImageTags?.Primary; if (item.Id && primaryTag) { - return `${baseUrl}/Items/${item.Id}/Images/Primary?quality=90&tag=${encodeURIComponent(primaryTag)}&width=500`; - } - - const backdropTag = item.BackdropImageTags?.[0]; - if (item.Id && backdropTag) { - return `${baseUrl}/Items/${item.Id}/Images/Backdrop/0?quality=90&tag=${encodeURIComponent(backdropTag)}&width=800`; + return { + url: + `${baseUrl}/Items/${item.Id}/Images/Primary` + + `?fillWidth=1920` + + `&fillHeight=1080` + + `&quality=90` + + `&tag=${encodeURIComponent(primaryTag)}`, + }; } return undefined; @@ -98,15 +128,18 @@ function sectionFromItems( const payloadItems = (items || []) .filter((item) => item.Id && item.Name) .slice(0, TV_DISCOVERY_ITEM_LIMIT) - .map((item) => ({ - id: item.Id!, - itemType: item.Type || undefined, - title: getTVDiscoveryTitle(item), - subtitle: getTVDiscoverySubtitle(item), - imageUrl: getTVDiscoveryImageUrl(item, api), - route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, - playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, - })); + .map((item) => { + const image = getTVDiscoveryImage(item, api); + return { + id: item.Id!, + itemType: item.Type || undefined, + title: getTVDiscoveryTitle(item), + subtitle: getTVDiscoverySubtitle(item), + imageUrl: image?.url, + route: `streamyfin://topshelf/item?id=${encodeURIComponent(item.Id!)}&type=${encodeURIComponent(item.Type || "")}`, + playRoute: `streamyfin://topshelf/play?id=${encodeURIComponent(item.Id!)}`, + }; + }); if (payloadItems.length === 0) return null;