Fix/tv interface android (#1576)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
lance chant
2026-05-22 09:43:04 +02:00
committed by GitHub
parent 11a4f14732
commit f8a84e34fd
10 changed files with 115 additions and 37 deletions

View File

@@ -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<MpvVideoSource | undefined>(() => {
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;

View File

@@ -69,6 +69,7 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
const queryString = queryParams.toString();

View File

@@ -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";

View File

@@ -207,7 +207,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
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<TVHeroCarouselProps> = ({
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
<View style={{ height: heroHeight, width: "100%" }}>
<View
style={{
height: heroHeight + insets.top,
width: "100%",
paddingTop: insets.top,
}}
>
{/* Backdrop layers with crossfade */}
<View
style={{

View File

@@ -438,7 +438,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
position: "relative",
width,
aspectRatio,
borderRadius: scaleSize(4),
borderRadius: scaleSize(24),
overflow: "hidden",
backgroundColor: "#1a1a1a",
borderWidth: scaleSize(2),

View File

@@ -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<Map<String, Any>> {

View File

@@ -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()

View File

@@ -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<Map<String, Any>> {

View File

@@ -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)
}

View File

@@ -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;