mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 18:24:18 +01:00
Compare commits
6 Commits
feat/andro
...
fix/skip-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980245f19d | ||
|
|
6dd98ccae8 | ||
|
|
228e847a4e | ||
|
|
15c66bc714 | ||
|
|
b4eb853048 | ||
|
|
c4209cbf8e |
@@ -7,6 +7,7 @@ import {
|
|||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
import {
|
||||||
|
getMediaInfoApi,
|
||||||
getPlaystateApi,
|
getPlaystateApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} 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
|
// Ref to store the stream fetch function for refreshing subtitle tracks
|
||||||
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||||
setStreamStatus({ isLoading: true, isError: false });
|
setStreamStatus({ isLoading: true, isError: false });
|
||||||
@@ -445,6 +477,12 @@ export default function DirectPlayerPage() {
|
|||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: currentTimeInTicks,
|
PositionTicks: currentTimeInTicks,
|
||||||
PlaySessionId: stream.sessionId,
|
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]);
|
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
// 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`;
|
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`;
|
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)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
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.Type === "Episode") {
|
||||||
if (item.ParentThumbImageTag) {
|
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
|||||||
@@ -139,9 +139,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// First try parent/series thumb (horizontal series artwork)
|
// First try parent/series thumb (horizontal series artwork).
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
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
|
// Fall back to episode's own primary image
|
||||||
if (item.ImageTags?.Primary) {
|
if (item.ImageTags?.Primary) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
|
|
||||||
@@ -34,11 +33,8 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
|
||||||
skipCredit: () => void;
|
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
|
||||||
skipCredit,
|
|
||||||
nextItem,
|
nextItem,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
@@ -180,21 +173,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
<SkipButton
|
{/* Smart Skip Credits behavior (drives Next Episode timing):
|
||||||
showButton={showSkipButton}
|
- "Skip Credits" button itself is rendered in SkipSegmentOverlay
|
||||||
onPress={skipIntro}
|
(independent of controls visibility).
|
||||||
buttonText='Skip Intro'
|
- "Next Episode" shows when credits extend to video end AND a next
|
||||||
/>
|
episode exists. */}
|
||||||
{/* 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'
|
|
||||||
/>
|
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
settings.autoPlayEpisodeCount <
|
settings.autoPlayEpisodeCount <
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
|||||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
|
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
|
||||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -338,6 +339,16 @@ export const Controls: FC<Props> = ({
|
|||||||
maxMs,
|
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(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
if (!item || !settings) {
|
if (!item || !settings) {
|
||||||
@@ -570,11 +581,8 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
|
||||||
skipCredit={skipCredit}
|
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
@@ -594,6 +602,17 @@ export const Controls: FC<Props> = ({
|
|||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</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 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
|
|||||||
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user