Compare commits

..

6 Commits

Author SHA1 Message Date
lance chant
980245f19d Merge branch 'develop' into fix/skip-button-rework 2026-07-17 14:47:48 +02:00
Gauvain
6dd98ccae8 fix(home): black episode thumbnails in Next Up & Continue Watching (#1815) 2026-07-17 12:00:23 +02:00
lance chant
228e847a4e Update components/video-player/controls/SkipSegmentOverlay.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 11:51:37 +02:00
Lance Chant
15c66bc714 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:45:42 +02:00
kevbot
b4eb853048 fix(livetv): release live streams so the tuner doesn't wedge (#1799)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gauvino <contact@uruk.dev>
2026-07-17 11:27:32 +02:00
Lance Chant
c4209cbf8e fix: fixing the skip button
Allowing it to show independently of the controls
Fixing the position of the button to fit over things better

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:02:23 +02:00
10 changed files with 216 additions and 46 deletions

View File

@@ -7,6 +7,7 @@ import {
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
getMediaInfoApi,
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
@@ -310,6 +311,37 @@ export default function DirectPlayerPage() {
// Ref to store the stream fetch function for refreshing subtitle tracks
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
// Live TV opens a server-side live stream via autoOpenLiveStream. If it is
// never closed, Jellyfin's M3U tuner limit fills up and every channel then
// fails with "simultaneous stream limit has been reached". reportPlaybackStopped
// handles a clean stop, but a channel switch (the player re-fetches in place)
// or an unmount after an error bypass it, so track the open live stream and
// release it on those paths too.
const apiRef = useRef(api);
useEffect(() => {
apiRef.current = api;
}, [api]);
const releaseLiveStream = useCallback(
(liveStreamId: string | null) => {
if (!liveStreamId || !apiRef.current || offline) return;
// Best effort: a failed close must not break teardown, and the slot is
// also freed by the server's reap as a backstop.
getMediaInfoApi(apiRef.current)
.closeLiveStream({ liveStreamId })
.catch(() => {});
},
[offline],
);
// The effect cleanup releases the live stream both when it changes (channel
// switch, which re-runs the effect) and when the player unmounts, so no
// manual previous-id tracking is needed.
useEffect(() => {
const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null;
return () => releaseLiveStream(liveStreamId);
}, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]);
useEffect(() => {
const fetchStreamData = async (): Promise<Stream | null> => {
setStreamStatus({ isLoading: true, isError: false });
@@ -445,6 +477,12 @@ export default function DirectPlayerPage() {
MediaSourceId: mediaSourceId,
PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
// Release the server-side live stream (and its tuner slot) on stop.
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
// the stop report carries its LiveStreamId; without it the stream leaks
// and Live TV eventually fails for everyone with "M3U simultaneous
// stream limit has been reached". Undefined for non-live items (no-op).
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
},
});
}, [api, item, mediaSourceId, stream, progress, offline]);

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"files": {
"includes": [
"**/*",

View File

@@ -100,7 +100,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
"@biomejs/biome": "2.5.3",
"@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",
@@ -267,23 +267,23 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.2.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-gEnLP7q9Iai0KlVxHDIdlrDgkvJ5vwPzL2+2ucz5BdPWd++Cf5GO1jPq92R4/85PrioviCZnlAD91Wx8WxPOjA=="],

View File

@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]);
// useEpisodePoster in deps so flipping the prop re-computes the URL live.
}, [api, item, useEpisodePoster]);
if (!url)
return <View className='aspect-video border border-neutral-800 w-44' />;

View File

@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const posterUrl = useMemo(() => {
if (!api) return null;
// For episodes, always use series thumb
// For episodes, always use series thumb.
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.Type === "Episode") {
if (item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;

View File

@@ -139,9 +139,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
if (orientation === "horizontal") {
// Episode: prefer series thumb image for consistent look (like hero section)
if (item.Type === "Episode") {
// First try parent/series thumb (horizontal series artwork)
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
// First try parent/series thumb (horizontal series artwork).
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
// Fall back to episode's own primary image
if (item.ImageTags?.Primary) {

View File

@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble";
@@ -34,11 +33,8 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
@@ -180,21 +173,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText='Skip Intro'
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
/>
{/* Smart Skip Credits behavior (drives Next Episode timing):
- "Skip Credits" button itself is rendered in SkipSegmentOverlay
(independent of controls visibility).
- "Next Episode" shows when credits extend to video end AND a next
episode exists. */}
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <

View File

@@ -38,6 +38,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime";
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -338,6 +339,16 @@ export const Controls: FC<Props> = ({
maxMs,
);
// Whether the "Next Episode" countdown will actually be rendered. The Skip
// Credits button yields to it only when this is true; if autoplay is
// disabled or its episode limit is reached, Skip Credits must stay available
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
const willShowNextEpisode =
!!nextItem &&
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) {
@@ -570,11 +581,8 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
@@ -594,6 +602,17 @@ export const Controls: FC<Props> = ({
time={isSliding || showRemoteBubble ? time : remoteTime}
/>
</Animated.View>
{/* Skip Intro / Skip Credits float independently of the controls so
they're visible (and tappable) without summoning the controls. */}
<SkipSegmentOverlay
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
willShowNextEpisode={willShowNextEpisode}
skipIntro={skipIntro}
skipCredit={skipCredit}
controlsVisible={showControls}
/>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (

View File

@@ -0,0 +1,125 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import SkipButton from "./SkipButton";
interface Props {
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
willShowNextEpisode: boolean;
skipIntro: () => void;
skipCredit: () => void;
controlsVisible: boolean;
}
// Offsets are relative to the safe-area insets so they hold in both portrait
// and landscape (the insets move with the notch / home indicator).
//
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
// spot and was working fine.
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
// of the chapters icon), while staying below the vertical volume
// slider which sits at the vertical middle of the screen.
const HIDDEN_BOTTOM = 24;
const HIDDEN_RIGHT = 12;
const VISIBLE_BOTTOM = 65;
const VISIBLE_RIGHT = 42;
const ANIM_DURATION = 250;
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
// itself instantly via a `hidden` (display:none) class, which would preempt
// the parent's opacity fade-out — lagging the flag keeps the button rendered
// (and visible) while the fade plays out.
const useDelayedHide = (value: boolean, duration: number): boolean => {
const [display, setDisplay] = useState(value);
useEffect(() => {
if (value) {
setDisplay(true);
return;
}
const t = setTimeout(() => setDisplay(false), duration);
return () => clearTimeout(t);
}, [value, duration]);
return value || display;
};
/**
* Floating Skip Intro / Skip Credits buttons shown independently of the
* player controls. They appear on their own during an intro or credits segment
* without the user having to summon the controls.
*/
export const SkipSegmentOverlay: FC<Props> = ({
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
willShowNextEpisode,
skipIntro,
skipCredit,
controlsVisible,
}) => {
const insets = useControlsSafeAreaInsets();
const showCredit =
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
const visible = showSkipButton || showCredit;
// Drive each SkipButton with a lagged flag so it stays visible while the
// opacity fade-out plays, instead of disappearing the instant its segment
// ends. `visible` above still drives opacity/pointerEvents immediately.
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: ANIM_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Position is recomputed on render (no slide animation) so the button never
// sweeps through an overlap zone while the controls toggle.
const bottom =
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
return (
<Animated.View
style={[styles.container, { right, bottom }, animatedStyle]}
pointerEvents={visible ? "box-none" : "none"}
>
<SkipButton
showButton={renderSkip}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={renderCredit}
onPress={skipCredit}
buttonText='Skip Credits'
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
flexDirection: "row",
gap: 8,
zIndex: 15,
},
});

View File

@@ -123,7 +123,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
"@biomejs/biome": "2.5.3",
"@biomejs/biome": "2.4.16",
"@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14",