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 1/3] 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; From 09bd84593c0aff834b9e10d5c2f1aac354857fa3 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Fri, 22 May 2026 11:43:30 +0200 Subject: [PATCH 2/3] Fix/tv interface android (#1579) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- constants/TVTypography.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index 833f617e..2d1c165b 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -47,10 +47,10 @@ export const useScaledTVTypography = () => { scaleMultipliers[TVTypographyScale.Default]; return { - display: Math.round(TVTypography.display * scale), - title: Math.round(TVTypography.title * scale), - heading: Math.round(TVTypography.heading * scale), - body: Math.round(TVTypography.body * scale), - callout: Math.round(TVTypography.callout * scale), + display: Math.round(scaleSize(TVTypography.display) * scale), + title: Math.round(scaleSize(TVTypography.title) * scale), + heading: Math.round(scaleSize(TVTypography.heading) * scale), + body: Math.round(scaleSize(TVTypography.body) * scale), + callout: Math.round(scaleSize(TVTypography.callout) * scale), }; }; From d272c6710c45dc18c62a86c985def2b566e6c092 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Fri, 22 May 2026 11:51:29 +0200 Subject: [PATCH 3/3] Fix/tv interface android (#1585) Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- constants/TVSizes.ts | 2 +- constants/TVTypography.ts | 43 ++++++++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/constants/TVSizes.ts b/constants/TVSizes.ts index 676609bc..403b8ebd 100644 --- a/constants/TVSizes.ts +++ b/constants/TVSizes.ts @@ -79,7 +79,7 @@ export const TVAnimation = { * Applied to poster sizes and gaps. */ const sizeScaleMultipliers: Record = { - [TVTypographyScale.Small]: 0.9, + [TVTypographyScale.Small]: 0.8, [TVTypographyScale.Default]: 1.0, [TVTypographyScale.Large]: 1.1, [TVTypographyScale.ExtraLarge]: 1.2, diff --git a/constants/TVTypography.ts b/constants/TVTypography.ts index 2d1c165b..d53726ad 100644 --- a/constants/TVTypography.ts +++ b/constants/TVTypography.ts @@ -1,46 +1,65 @@ import { TVTypographyScale, useSettings } from "@/utils/atoms/settings"; +import { scaleSize } from "@/utils/scaleSize"; /** * TV Typography Scale * * Consistent text sizes for TV interface components. - * Design values are for 1920×1080 and scaled proportionally - * to the actual viewport via scaleSize(). + * Base values are designed for 1920x1080 and scaled to the actual viewport via + * scaleSize(), then further adjusted by the user's tvTypographyScale setting. */ -import { scaleSize } from "@/utils/scaleSize"; +// ============================================================================= +// BASE VALUES (at Default scale) +// ============================================================================= export const TVTypography = { /** Hero titles, movie/show names */ - display: scaleSize(70), + display: 70, /** Episode series name, major headings */ - title: scaleSize(42), + title: 42, /** Section headers (Cast, Technical Details, From this Series) */ - heading: scaleSize(32), + heading: 32, /** Overview, actor names, card titles, metadata */ - body: scaleSize(40), + body: 40, /** Secondary text, labels, subtitles */ - callout: scaleSize(26), + callout: 26, }; export type TVTypographyKey = keyof typeof TVTypography; +// ============================================================================= +// SCALING +// ============================================================================= + const scaleMultipliers: Record = { - [TVTypographyScale.Small]: 0.85, + [TVTypographyScale.Small]: 0.8, [TVTypographyScale.Default]: 1.0, - [TVTypographyScale.Large]: 1.2, - [TVTypographyScale.ExtraLarge]: 1.4, + [TVTypographyScale.Large]: 1.1, + [TVTypographyScale.ExtraLarge]: 1.2, +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +export type ScaledTVTypography = { + display: number; + title: number; + heading: number; + body: number; + callout: number; }; /** * Hook that returns scaled TV typography values based on user settings. * Use this instead of the static TVTypography constant for dynamic scaling. */ -export const useScaledTVTypography = () => { +export const useScaledTVTypography = (): ScaledTVTypography => { const { settings } = useSettings(); const scale = scaleMultipliers[settings.tvTypographyScale] ??