Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
0feb4bab20 chore(deps): update dependency typescript to ~5.9.0 2025-08-19 01:24:31 +00:00
24 changed files with 984 additions and 1643 deletions

View File

@@ -20,7 +20,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
- uses: amannn/action-semantic-pull-request@fdd4d3ddf614fbcd8c29e4b106d3bbe0cb2c605d # v6.0.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -181,12 +181,6 @@ Thanks to the following contributors for their significant contributions:
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lancechant">
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lancechant</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
@@ -219,12 +213,6 @@ Thanks to the following contributors for their significant contributions:
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Gauvino">
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Gauvino</b></sub>
</a>
</td>
</tr>
</table>
</div>

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.32.0",
"version": "0.31.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 61,
"versionCode": 59,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -69,7 +69,7 @@
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.6.1",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-udp": "^4.1.7",
@@ -98,7 +98,7 @@
"lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "~5.8.3",
"typescript": "~5.9.0",
},
},
},
@@ -1646,7 +1646,7 @@
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.4.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA=="],
"react-native-screens": ["react-native-screens@4.11.1", "", { "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.1.7", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw=="],
@@ -1890,7 +1890,7 @@
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"ua-parser-js": ["ua-parser-js@0.7.40", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ=="],

View File

@@ -106,17 +106,20 @@ export const DownloadItems: React.FC<DownloadProps> = ({
// Initialize selectedOptions with default values
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
if (itemsNotDownloaded.length === 1) {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
itemsNotDownloaded.length,
]);
const itemsToDownload = useMemo(() => {

View File

@@ -186,7 +186,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
<ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import Animated, { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { formatTimeString } from "@/utils/time";
import { SLIDER_CONFIG, SLIDER_THEME } from "../constants";
import NextEpisodeCountDownButton from "../NextEpisodeCountDownButton";
import SkipButton from "../SkipButton";
import { TrickplayBubble } from "./TrickplayBubble";
interface BottomControlsProps {
item: BaseItemDto;
showControls: boolean;
isSliding: boolean;
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
isVlc: boolean;
nextItem?: BaseItemDto;
showSkipButton: boolean;
showSkipCreditButton: boolean;
cacheProgress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
effectiveProgress: SharedValue<number>;
animatedControlsStyle: any;
animatedSliderStyle: any;
trickPlayUrl?: {
x: number;
y: number;
url: string;
};
trickplayInfo?: {
aspectRatio: number;
data: {
TileWidth?: number;
TileHeight?: number;
};
};
time: {
hours: number;
minutes: number;
seconds: number;
};
getEndTime: () => string;
onControlsInteraction: () => void;
onTouchStart: () => void;
onTouchEnd: () => void;
onSliderStart: () => void;
onSliderComplete: (value: number) => void;
onSliderChange: (value: number) => void;
onSkipIntro: () => void;
onSkipCredit: () => void;
onNextEpisodeAutoPlay: () => void;
onNextEpisodeManual: () => void;
}
export const BottomControls: React.FC<BottomControlsProps> = ({
item,
showControls,
isSliding,
showRemoteBubble,
currentTime,
remainingTime,
isVlc,
nextItem,
showSkipButton,
showSkipCreditButton,
cacheProgress,
min,
max,
effectiveProgress,
animatedControlsStyle,
animatedSliderStyle,
trickPlayUrl,
trickplayInfo,
time,
getEndTime,
onControlsInteraction,
onTouchStart,
onTouchEnd,
onSliderStart,
onSliderComplete,
onSliderChange,
onSkipIntro,
onSkipCredit,
onNextEpisodeAutoPlay,
onNextEpisodeManual,
}) => {
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const renderTrickplayBubble = () => (
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
/>
);
return (
<Animated.View
style={[
{
position: "absolute",
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
bottom: settings?.safeAreaInControlsEnabled
? Math.max(insets.bottom - 17, 0)
: 0,
},
animatedControlsStyle,
]}
className={"flex flex-col px-2"}
onTouchStart={onControlsInteraction}
>
<View
className='shrink flex flex-col justify-center h-full'
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end", // Shrink height based on content
}}
pointerEvents={showControls ? "box-none" : "none"}
>
{item?.Type === "Episode" && (
<Text className='opacity-50'>
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
</Text>
)}
<Text className='font-bold text-xl'>{item?.Name}</Text>
{item?.Type === "Movie" && (
<Text className='text-xs opacity-50'>{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
</View>
<View className='flex flex-row space-x-2'>
<SkipButton
showButton={showSkipButton}
onPress={onSkipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={onSkipCredit}
buttonText='Skip Credits'
/>
{(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <
settings.maxAutoPlayEpisodeCount.value) && (
<NextEpisodeCountDownButton
show={
!nextItem
? false
: isVlc
? remainingTime < 10000
: remainingTime < 10
}
onFinish={onNextEpisodeAutoPlay}
onPress={onNextEpisodeManual}
/>
)}
</View>
</View>
<View
className={"flex flex-col-reverse rounded-lg items-center my-2"}
pointerEvents={showControls ? "box-none" : "none"}
>
<View className={"flex flex-col w-full shrink"}>
<View
style={{
height: SLIDER_CONFIG.HEIGHT,
justifyContent: "center",
alignItems: "stretch",
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<Animated.View style={animatedSliderStyle}>
<Slider
theme={SLIDER_THEME}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={onSliderStart}
onSlidingComplete={onSliderComplete}
onValueChange={onSliderChange}
containerStyle={{
borderRadius: SLIDER_CONFIG.BORDER_RADIUS,
}}
renderBubble={() =>
(isSliding || showRemoteBubble) && renderTrickplayBubble()
}
sliderHeight={SLIDER_CONFIG.HEIGHT}
thumbWidth={SLIDER_CONFIG.THUMB_WIDTH}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>
</Animated.View>
</View>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-[12px] text-neutral-400'>
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text>
<View className='flex flex-col items-end'>
<Text className='text-[12px] text-neutral-400'>
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
ends at {getEndTime()}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
);
};

View File

@@ -1,162 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "../AudioSlider";
import BrightnessSlider from "../BrightnessSlider";
interface CenterControlsProps {
showControls: boolean;
showAudioSlider: boolean;
isPlaying: boolean;
isBuffering: boolean;
rewindSkipTime?: number;
forwardSkipTime?: number;
animatedControlsStyle: any;
setShowAudioSlider: (show: boolean) => void;
onTogglePlay: () => void;
onSkipBackward: () => void;
onSkipForward: () => void;
}
export const CenterControls: React.FC<CenterControlsProps> = ({
showControls,
showAudioSlider,
isPlaying,
isBuffering,
rewindSkipTime,
forwardSkipTime,
animatedControlsStyle,
setShowAudioSlider,
onTogglePlay,
onSkipBackward,
onSkipForward,
}) => {
const [settings] = useSettings();
const insets = useSafeAreaInsets();
return (
<Animated.View
style={[
{
position: "absolute",
top: "50%", // Center vertically
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: 17,
},
animatedControlsStyle,
]}
pointerEvents={showControls ? "box-none" : "none"}
>
{/* Brightness Control */}
<View
style={{
width: 50,
height: 50,
alignItems: "center",
justifyContent: "center",
transform: [{ rotate: "270deg" }],
}}
>
<BrightnessSlider />
</View>
{/* Skip Backward */}
{!Platform.isTV && (
<TouchableOpacity onPress={onSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
{/* Play/Pause Button */}
<View style={{ alignItems: "center" }}>
<TouchableOpacity onPress={onTogglePlay}>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
</View>
{/* Skip Forward */}
{!Platform.isTV && (
<TouchableOpacity onPress={onSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
{/* Volume/Audio Control */}
<View
style={{
width: 50,
height: 50,
alignItems: "center",
justifyContent: "center",
transform: [{ rotate: "270deg" }],
opacity: showAudioSlider || showControls ? 1 : 0,
}}
>
<AudioSlider setVisibility={setShowAudioSlider} />
</View>
</Animated.View>
);
};

View File

@@ -1,166 +0,0 @@
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { TrackInfo } from "@/modules/VlcPlayer.types";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { VideoProvider } from "../contexts/VideoContext";
import DropdownView from "../dropdown/DropdownView";
import { type ScaleFactor, ScaleFactorSelector } from "../ScaleFactorSelector";
import {
type AspectRatio,
AspectRatioSelector,
} from "../VideoScalingModeSelector";
interface TopControlsBarProps {
item: BaseItemDto;
mediaSource?: MediaSourceInfo | null;
offline: boolean;
showControls: boolean;
aspectRatio: AspectRatio;
scaleFactor: ScaleFactor;
previousItem?: BaseItemDto;
nextItem?: BaseItemDto;
animatedControlsStyle: any;
screenWidth: number;
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
startPictureInPicture?: () => Promise<void>;
onAspectRatioChange: (ratio: AspectRatio) => void;
onScaleFactorChange: (scale: ScaleFactor) => void;
onEpisodeModeToggle: () => void;
onGoToPreviousItem: () => void;
onGoToNextItem: () => void;
onClose: () => void;
}
export const TopControlsBar: React.FC<TopControlsBarProps> = ({
item,
mediaSource,
offline,
showControls,
aspectRatio,
scaleFactor,
previousItem,
nextItem,
animatedControlsStyle,
screenWidth,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
startPictureInPicture,
onAspectRatioChange,
onScaleFactorChange,
onEpisodeModeToggle,
onGoToPreviousItem,
onGoToNextItem,
onClose,
}) => {
const [settings] = useSettings();
const insets = useSafeAreaInsets();
return (
<Animated.View
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
width: settings?.safeAreaInControlsEnabled
? screenWidth - insets.left - insets.right
: screenWidth,
},
animatedControlsStyle,
]}
pointerEvents={showControls ? "auto" : "none"}
className={"flex flex-row w-full pt-2"}
>
<View className='mr-auto'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<DropdownView />
</VideoProvider>
)}
</View>
<View className='flex flex-row items-center space-x-2 '>
{!Platform.isTV &&
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
Platform.OS === "android") && (
<TouchableOpacity
onPress={startPictureInPicture}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<MaterialIcons
name='picture-in-picture'
size={24}
color='white'
style={{ opacity: showControls ? 1 : 0 }}
/>
</TouchableOpacity>
)}
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={onEpisodeModeToggle}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='list' size={24} color='white' />
</TouchableOpacity>
)}
{previousItem && (
<TouchableOpacity
onPress={onGoToPreviousItem}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='play-skip-back' size={24} color='white' />
</TouchableOpacity>
)}
{nextItem && (
<TouchableOpacity
onPress={onGoToNextItem}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='play-skip-forward' size={24} color='white' />
</TouchableOpacity>
)}
{/* Video Controls */}
<AspectRatioSelector
currentRatio={aspectRatio}
onRatioChange={onAspectRatioChange}
disabled={!setVideoAspectRatio}
/>
<ScaleFactorSelector
currentScale={scaleFactor}
onScaleChange={onScaleFactorChange}
disabled={!setVideoScaleFactor}
/>
<TouchableOpacity
onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
</Animated.View>
);
};

View File

@@ -1,94 +0,0 @@
import { Image } from "expo-image";
import React from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import {
calculateTrickplayDimensions,
formatTimeForBubble,
} from "../utils/trickplayUtils";
interface TrickplayBubbleProps {
trickPlayUrl?: {
x: number;
y: number;
url: string;
};
trickplayInfo?: {
aspectRatio: number;
data: {
TileWidth?: number;
TileHeight?: number;
};
};
time: {
hours: number;
minutes: number;
seconds: number;
};
}
export const TrickplayBubble: React.FC<TrickplayBubbleProps> = ({
trickPlayUrl,
trickplayInfo,
time,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const { tileWidth, tileHeight, scaledWidth } = calculateTrickplayDimensions(
trickplayInfo.aspectRatio,
);
return (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: scaledWidth,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className='bg-neutral-800 overflow-hidden'
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * (trickplayInfo.data.TileWidth || 1),
height:
(150 / trickplayInfo.aspectRatio) *
(trickplayInfo.data.TileHeight || 1),
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit='cover'
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{formatTimeForBubble(time)}
</Text>
</View>
);
};

View File

@@ -1,28 +0,0 @@
export const CONTROLS_TIMEOUT = 4000;
export const TRICKPLAY_TILE_WIDTH = 150;
export const TRICKPLAY_TILE_SCALE = 1.4;
export const SLIDER_SCALE_UP = 1.4;
export const SLIDER_SCALE_NORMAL = 1.0;
export const ANIMATION_DURATION = {
CONTROLS_FADE: 300,
SLIDER_SCALE: 300,
SLIDER_SCALE_COMPLETE: 200,
} as const;
export const SLIDER_CONFIG = {
HEIGHT: 10,
THUMB_WIDTH: 0,
BORDER_RADIUS: 100,
} as const;
export const SLIDER_THEME = {
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
} as const;

View File

@@ -85,6 +85,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
}) => {
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
@@ -114,6 +115,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) {
setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(),

View File

@@ -1,169 +0,0 @@
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback } from "react";
import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface UseEpisodeNavigationProps {
item: BaseItemDto;
offline: boolean;
mediaSource?: MediaSourceInfo | null;
}
export const useEpisodeNavigation = ({
item,
offline,
mediaSource,
}: UseEpisodeNavigationProps) => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
subtitleIndex: string;
}>();
const { previousItem, nextItem } = usePlaybackManager({
item,
isOffline: offline,
});
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) {
return;
}
lightHapticFeedback();
const previousIndexes = {
subtitleIndex: subtitleIndex
? Number.parseInt(subtitleIndex, 10)
: undefined,
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
};
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
item,
settings,
previousIndexes,
mediaSource ?? undefined,
);
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
},
[
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
lightHapticFeedback,
],
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
return;
}
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon, settings, updateSettings],
);
// Memoized handlers
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
return {
previousItem,
nextItem,
goToItemCommon,
goToPreviousItem,
goToNextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
handleContinueWatching,
};
};

View File

@@ -1,212 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useEffect, useRef, useState } from "react";
import { useTVEventHandler } from "react-native";
import type { SharedValue } from "react-native-reanimated";
import { useTrickplay } from "@/hooks/useTrickplay";
import { msToTicks, secondsToMs, ticksToSeconds } from "@/utils/time";
interface UseRemoteControlsProps {
progress: SharedValue<number>;
min: SharedValue<number>;
max: SharedValue<number>;
isVlc: boolean;
showControls: boolean;
isPlaying: boolean;
item: BaseItemDto;
seek: (ticks: number) => void;
play: () => void;
togglePlay: () => void;
toggleControls: () => void;
}
export const useRemoteControls = ({
progress,
min,
max,
isVlc,
showControls,
isPlaying,
item,
seek,
play,
togglePlay,
toggleControls,
}: UseRemoteControlsProps) => {
const { calculateTrickplayUrl } = useTrickplay(item);
const remoteScrubProgress = useRef<SharedValue<number | null>>(null);
const isRemoteScrubbing = useRef<SharedValue<boolean>>(null);
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
const [longPressScrubMode, setLongPressScrubMode] = useState<
"FF" | "RW" | null
>(null);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// Initialize shared values if not provided
if (!remoteScrubProgress.current) {
remoteScrubProgress.current = { value: null } as SharedValue<number | null>;
}
if (!isRemoteScrubbing.current) {
isRemoteScrubbing.current = { value: false } as SharedValue<boolean>;
}
useTVEventHandler((evt) => {
if (!evt) return;
switch (evt.eventType) {
case "longLeft": {
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
break;
}
case "longRight": {
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
break;
}
case "left":
case "right": {
isRemoteScrubbing.current!.value = true;
setShowRemoteBubble(true);
const direction = evt.eventType === "left" ? -1 : 1;
const base = remoteScrubProgress.current!.value ?? progress.value;
const updated = Math.max(
min.value,
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.current!.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
break;
}
case "select": {
if (
isRemoteScrubbing.current!.value &&
remoteScrubProgress.current!.value != null
) {
progress.value = remoteScrubProgress.current!.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.current!.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.current!.value));
seek(seekTarget);
if (isPlaying) play();
isRemoteScrubbing.current!.value = false;
remoteScrubProgress.current!.value = null;
setShowRemoteBubble(false);
} else {
togglePlay();
}
break;
}
case "down":
case "up":
// cancel scrubbing on other directions
isRemoteScrubbing.current!.value = false;
remoteScrubProgress.current!.value = null;
setShowRemoteBubble(false);
break;
default:
break;
}
if (!showControls) toggleControls();
});
const handleSeekBackward = (
seconds: number,
wasPlayingRef: React.MutableRefObject<boolean>,
) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
seek(newTime);
}
} catch (error) {
console.error("Error seeking video backwards", error);
}
};
const handleSeekForward = (
seconds: number,
wasPlayingRef: React.MutableRefObject<boolean>,
) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
seek(Math.max(0, newTime));
}
} catch (error) {
console.error("Error seeking video forwards", error);
}
};
// Long press scrubbing effect
useEffect(() => {
let isActive = true;
let seekTime = 10;
if (longPressScrubMode) {
// Function is used, but eslint doesn't detect it inside setTimeout
const scrubWithLongPress = (
wasPlayingRef: React.MutableRefObject<boolean>,
) => {
if (!isActive || !longPressScrubMode) return;
const scrubFn =
longPressScrubMode === "FF"
? (time: number) => handleSeekForward(time, wasPlayingRef)
: (time: number) => handleSeekBackward(time, wasPlayingRef);
scrubFn(seekTime);
seekTime *= 1.1;
longPressTimeoutRef.current = setTimeout(
() => scrubWithLongPress(wasPlayingRef),
300,
);
};
// Start the scrubbing
const wasPlayingRef = { current: isPlaying };
scrubWithLongPress(wasPlayingRef);
}
return () => {
isActive = false;
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}
};
}, [longPressScrubMode, handleSeekBackward, handleSeekForward, isPlaying]);
return {
remoteScrubProgress: remoteScrubProgress.current,
isRemoteScrubbing: isRemoteScrubbing.current,
showRemoteBubble,
longPressScrubMode,
time,
handleSeekBackward,
handleSeekForward,
};
};

View File

@@ -1,75 +0,0 @@
import { useCallback, useRef } from "react";
import type { SharedValue } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { secondsToMs, ticksToSeconds } from "@/utils/time";
interface UseSkipControlsProps {
progress: SharedValue<number>;
isPlaying: boolean;
isVlc: boolean;
seek: (ticks: number) => void;
play: () => void;
}
export const useSkipControls = ({
progress,
isPlaying,
isVlc,
seek,
play,
}: UseSkipControlsProps) => {
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const wasPlayingRef = useRef(false);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) {
return;
}
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
seek(newTime);
if (wasPlayingRef.current) {
play();
}
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) {
return;
}
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current) {
play();
}
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc, play, seek, lightHapticFeedback]);
return {
handleSkipBackward,
handleSkipForward,
};
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { debounce } from "lodash";
import { useCallback, useRef, useState } from "react";
import {
type SharedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useTrickplay } from "@/hooks/useTrickplay";
import { msToTicks, ticksToSeconds } from "@/utils/time";
interface UseSliderInteractionsProps {
progress: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isPlaying: boolean;
isVlc: boolean;
showControls: boolean;
item: BaseItemDto;
seek: (ticks: number) => void;
play: () => void;
pause: () => void;
}
export const useSliderInteractions = ({
progress,
isSeeking,
isPlaying,
isVlc,
showControls,
item,
seek,
play,
pause,
}: UseSliderInteractionsProps) => {
const [isSliding, setIsSliding] = useState(false);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
// Animated scale for slider
const sliderScale = useSharedValue(1);
const { calculateTrickplayUrl } = useTrickplay(item);
const handleSliderStart = useCallback(() => {
if (!showControls) {
return;
}
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying, pause]);
const handleTouchStart = useCallback(() => {
if (!showControls) {
return;
}
// Scale up the slider immediately on touch
sliderScale.value = withTiming(1.4, { duration: 300 });
}, [showControls]);
const handleTouchEnd = useCallback(() => {
if (!showControls) {
return;
}
// Scale down the slider on touch end (only if not sliding, to avoid conflict with onSlidingComplete)
if (!isSliding) {
sliderScale.value = withTiming(1.0, { duration: 300 });
}
}, [showControls, isSliding]);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
// Scale down the slider
sliderScale.value = withTiming(1.0, { duration: 200 });
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current) {
play();
}
},
[isVlc, seek, play],
);
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = isVlc ? msToTicks(value) : value;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 3),
[isVlc, calculateTrickplayUrl],
);
return {
isSliding,
setIsSliding,
time,
sliderScale,
handleSliderStart,
handleTouchStart,
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
};
};

View File

@@ -1,69 +0,0 @@
import { useCallback, useState } from "react";
import {
runOnJS,
type SharedValue,
useAnimatedReaction,
} from "react-native-reanimated";
import { ticksToSeconds } from "@/utils/time";
interface UseTimeManagementProps {
progress: SharedValue<number>;
max: SharedValue<number>;
isSeeking: SharedValue<boolean>;
isVlc: boolean;
}
export const useTimeManagement = ({
progress,
max,
isSeeking,
isVlc,
}: UseTimeManagementProps) => {
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
const remaining = isVlc
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
},
[isVlc],
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (!result.isSeeking) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes],
);
const getEndTime = () => {
const now = new Date();
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
const finishTime = new Date(now.getTime() + remainingMs);
return finishTime.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return {
currentTime,
remainingTime,
updateTimes,
getEndTime,
};
};

View File

@@ -1,43 +0,0 @@
import { type Dispatch, type SetStateAction, useCallback } from "react";
import type { ScaleFactor } from "../ScaleFactorSelector";
import type { AspectRatio } from "../VideoScalingModeSelector";
interface UseVideoScalingProps {
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
}
export const useVideoScaling = ({
setAspectRatio,
setScaleFactor,
setVideoAspectRatio,
setVideoScaleFactor,
}: UseVideoScalingProps) => {
const handleAspectRatioChange = useCallback(
async (newRatio: AspectRatio) => {
if (!setAspectRatio || !setVideoAspectRatio) return;
setAspectRatio(newRatio);
const aspectRatioString = newRatio === "default" ? null : newRatio;
await setVideoAspectRatio(aspectRatioString);
},
[setAspectRatio, setVideoAspectRatio],
);
const handleScaleFactorChange = useCallback(
async (newScale: ScaleFactor) => {
if (!setScaleFactor || !setVideoScaleFactor) return;
setScaleFactor(newScale);
await setVideoScaleFactor(newScale);
},
[setScaleFactor, setVideoScaleFactor],
);
return {
handleAspectRatioChange,
handleScaleFactorChange,
};
};

View File

@@ -1,14 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { ticksToMs } from "@/utils/time";
export const initializeProgress = (item: BaseItemDto, isVlc: boolean) => {
const initialProgress = isVlc
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
const maxProgress = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
return { initialProgress, maxProgress };
};

View File

@@ -1,23 +0,0 @@
import { TRICKPLAY_TILE_SCALE, TRICKPLAY_TILE_WIDTH } from "../constants";
export const calculateTrickplayDimensions = (aspectRatio: number) => {
const tileWidth = TRICKPLAY_TILE_WIDTH;
const tileHeight = TRICKPLAY_TILE_WIDTH / aspectRatio;
return {
tileWidth,
tileHeight,
scaledWidth: tileWidth * TRICKPLAY_TILE_SCALE,
scaledHeight: tileHeight * TRICKPLAY_TILE_SCALE,
};
};
export const formatTimeForBubble = (time: {
hours: number;
minutes: number;
seconds: number;
}) => {
return `${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
};

View File

@@ -46,14 +46,14 @@
},
"production": {
"environment": "production",
"channel": "0.32.0",
"channel": "0.31.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.32.0",
"channel": "0.31.0",
"android": {
"buildType": "apk",
"image": "latest"
@@ -61,7 +61,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.32.0",
"channel": "0.31.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -84,7 +84,7 @@
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.6.1",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-udp": "^4.1.7",
@@ -113,7 +113,7 @@
"lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "~5.8.3"
"typescript": "~5.9.0"
},
"expo": {
"install": {

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.32.0" },
clientInfo: { name: "Streamyfin", version: "0.31.0" },
deviceInfo: {
name: deviceName,
id,
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.32.0"`,
}, DeviceId="${deviceId}", Version="0.31.0"`,
};
}, [deviceId]);