Compare commits

..

30 Commits

Author SHA1 Message Date
68ee4405bc utils/profiles/download.js aktualisiert 2025-08-21 08:21:31 +02:00
a7b06b8773 .github/workflows/build-android.yml aktualisiert 2025-08-20 21:55:49 +02:00
405503c3e8 .github/workflows/build-android.yml aktualisiert 2025-08-20 20:06:52 +02:00
b774b3ceab .github/workflows/build-android.yml aktualisiert 2025-08-20 20:05:29 +02:00
3f292d2d91 .github/workflows/build-android.yml aktualisiert 2025-08-20 20:02:33 +02:00
91ea6301cf utils/profiles/download.js aktualisiert 2025-08-20 18:39:06 +02:00
Fredrik Burmester
7cab50750f chore: version
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Failing after 6s
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Failing after 4s
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Failing after 5s
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been skipped
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been skipped
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Failing after 4s
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Failing after 4s
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Successful in 4s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Failing after 14s
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Failing after 14s
2025-08-20 10:30:57 +02:00
Fredrik Burmester
d795e82581 fix: trickplay and re-rendering issues 2025-08-20 09:59:03 +02:00
Fredrik Burmester
e7161bc9ab fix: revert fade in controls 2025-08-20 08:21:01 +02:00
Fredrik Burmester
8e74363f32 Revert "chore: refactor controls (#946)"
This reverts commit 8389404975.
2025-08-20 08:18:12 +02:00
Alex
1cb28788d6 Fix selecting bit rate on whole series downloads (#956)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-20 00:12:51 +10:00
renovate[bot]
ff9f855d4c chore(deps): update amannn/action-semantic-pull-request action to v6.1.0 (#953) 2025-08-19 13:37:47 +02:00
Fredrik Burmester
13df2d1077 chore: version 2025-08-19 10:01:34 +02:00
Fredrik Burmester
8389404975 chore: refactor controls (#946)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-19 09:02:56 +02:00
Fredrik Burmester
cd920e2d84 fix: small design change 2025-08-19 08:10:54 +02:00
Gauvain
92a11c18e0 docs: add new contributors to README (#951) 2025-08-19 04:25:49 +02:00
Gauvain
e05f10fe42 ci: add actions language to CodeQL analysis matrix
Expands security scanning to include GitHub Actions workflows alongside existing JavaScript/TypeScript analysis for more comprehensive code security coverage
2025-08-19 01:09:23 +02:00
renovate[bot]
2540ae22ce chore(deps): update actions/dependency-review-action action to v4.7.2 (#950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 01:08:50 +02:00
Gauvain
f490957091 ci: add iOS 18.0 SDK installation step (#949) 2025-08-19 01:06:28 +02:00
renovate[bot]
a146fc8810 chore(deps): update dependency @biomejs/biome to v2.2.0 (#934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-18 23:00:33 +02:00
renovate[bot]
100d7e0830 chore(deps): update github/codeql-action action to v3.29.10 (#948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 21:18:54 +02:00
Fredrik Burmester
ebcdd5bbf7 feat: show when the stream ends, not only remaining time (#944) 2025-08-18 14:57:02 +02:00
lance chant
18b33884e6 fix: settings storage calc (#943)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-08-18 14:53:58 +02:00
Fredrik Burmester
9410239c48 feat: scale factor and aspect ratio (#942) 2025-08-18 14:24:45 +02:00
Fredrik Burmester
4fed25a3ab chore: version 2025-08-18 09:17:24 +02:00
Fredrik Burmester
a8810cae8a Merge branch 'feat/fade-in-controls' into develop 2025-08-18 09:16:38 +02:00
Fredrik Burmester
aff009de92 chore: version 2025-08-18 07:48:57 +02:00
Alex
1924efbef2 Fix more bugs (#939)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
2025-08-17 15:25:51 +10:00
Gauvain
24d006742b Merge branch 'develop' into feat/fade-in-controls 2025-08-13 20:32:53 +02:00
Fredrik Burmester
c34c7fbe83 feat: fade in the controls (instead of on/off toggle) 2025-08-13 15:27:47 +02:00
36 changed files with 791 additions and 277 deletions

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4.3.0 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -38,7 +38,7 @@ jobs:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@v4.2.4 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -94,7 +94,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v3 # v4.6.2
with:
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: |

View File

@@ -58,13 +58,21 @@ jobs:
else
bun run prebuild
fi
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⚙️ Ensure iOS/tvOS SDKs installed
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
xcodebuild -downloadPlatform tvOS
else
xcodebuild -downloadPlatform iOS
fi
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}

View File

@@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
language: [ 'javascript-typescript', 'actions' ]
steps:
- name: 📥 Checkout repository
@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10

View File

@@ -20,7 +20,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@fdd4d3ddf614fbcd8c29e4b106d3bbe0cb2c605d # v6.0.1
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0

View File

@@ -181,6 +181,12 @@ 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">
@@ -213,6 +219,12 @@ 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.30.0",
"version": "0.32.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 57,
"versionCode": 62,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -39,26 +39,44 @@ export default function page() {
}
}, [getDownloadedItems]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
episodeSeasonIndex ||
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
const seasons: Record<string, BaseItemDto[]> = {};
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
return seasonGroups[Number(seasonIndex)] ?? [];
}, [seasonGroups, seasonIndex]);
const initialSeasonIndex = useMemo(
() =>
@@ -102,7 +120,7 @@ export default function page() {
<View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {

View File

@@ -2,7 +2,7 @@ import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
@@ -73,13 +73,13 @@ export default function settings() {
<OtherSettings />
<DownloadSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
<AppLanguageSelector />
<ChromecastSettings />
{!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}>
<ListItem
@@ -112,7 +112,7 @@ export default function settings() {
</ListGroup>
</View>
<StorageSettings />
{!Platform.isTV && <StorageSettings />}
</View>
</ScrollView>
);

View File

@@ -10,7 +10,7 @@ import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
export default function page() {
export default function Page() {
const navigation = useNavigation();
const { logs } = useLog();
const { t } = useTranslation();
@@ -28,10 +28,12 @@ export default function page() {
const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<Record<string, boolean>>({});
const [order, setOrder] = useState<"asc" | "desc">("desc");
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
const _orderId = useId();
const _levelsId = useId();
const filteredLogs = useMemo(
() =>
logs

View File

@@ -15,8 +15,8 @@ import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -38,12 +38,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
@@ -53,11 +50,12 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
// Load persisted state from storage
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
return saved ?? false;
});
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
@@ -82,11 +80,6 @@ export default function page() {
lightHapticFeedback();
}, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const {
itemId,
audioIndex: audioIndexStr,
@@ -105,8 +98,8 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const [_settings] = useSettings();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -287,11 +280,15 @@ export default function page() {
]);
const stop = useCallback(() => {
// Update URL with final playback position before stopping
router.setParams({
playbackPosition: msToTicks(progress.get()).toString(),
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped]);
}, [videoRef, reportPlaybackStopped, progress]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -300,7 +297,7 @@ export default function page() {
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
const currentPlayStateInfo = useCallback(() => {
if (!stream) return;
return {
itemId: item?.Id!,
@@ -316,7 +313,32 @@ export default function page() {
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
}, [
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
isMuted,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
// Track when seeking ends to update URL immediately
useAnimatedReaction(
() => isSeeking.get(),
(currentSeeking, previousSeeking) => {
if (previousSeeking && !currentSeeking) {
// Seeking just ended
wasJustSeeking.value = true;
}
},
[],
);
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
@@ -329,10 +351,20 @@ export default function page() {
progress.set(currentTime);
// Update the playback position in the URL.
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();
wasJustSeeking.value = false;
if (
shouldUpdateUrl ||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
if (!item?.Id) return;
@@ -405,6 +437,7 @@ export default function page() {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -519,7 +552,7 @@ export default function page() {
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
const initOptions = [``];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
@@ -544,6 +577,54 @@ export default function page() {
return () => setIsMounted(false);
}, []);
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useMemo(
() => videoRef.current?.startPictureInPicture,
[isVideoLoaded],
);
const play = useMemo(
() => videoRef.current?.play || (() => {}),
[isVideoLoaded],
);
const pause = useMemo(
() => videoRef.current?.pause || (() => {}),
[isVideoLoaded],
);
const seek = useMemo(
() => videoRef.current?.seekTo || (() => {}),
[isVideoLoaded],
);
const getAudioTracks = useMemo(
() => videoRef.current?.getAudioTracks,
[isVideoLoaded],
);
const getSubtitleTracks = useMemo(
() => videoRef.current?.getSubtitleTracks,
[isVideoLoaded],
);
const setSubtitleTrack = useMemo(
() => videoRef.current?.setSubtitleTrack,
[isVideoLoaded],
);
const setSubtitleURL = useMemo(
() => videoRef.current?.setSubtitleURL,
[isVideoLoaded],
);
const setAudioTrack = useMemo(
() => videoRef.current?.setAudioTrack,
[isVideoLoaded],
);
const setVideoAspectRatio = useMemo(
() => videoRef.current?.setVideoAspectRatio,
[isVideoLoaded],
);
const setVideoScaleFactor = useMemo(
() => videoRef.current?.setVideoScaleFactor,
[isVideoLoaded],
);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
@@ -571,7 +652,14 @@ export default function page() {
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View
style={{
display: "flex",
@@ -580,8 +668,6 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView
@@ -625,20 +711,24 @@ export default function page() {
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
setSubtitleURL={videoRef.current?.setSubtitleURL}
setAudioTrack={videoRef.current?.setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
/>
)}

View File

@@ -1,14 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"files": {
"includes": [
"**/*",
"!node_modules/**",
"!ios/**",
"!android/**",
"!Streamyfin.app/**",
"!utils/jellyseerr/**",
"!.expo/**"
"!node_modules",
"!ios",
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
"!.expo"
]
},
"linter": {

View File

@@ -65,7 +65,7 @@
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.8",
"react-native-mmkv": "3.3.0",
"react-native-mmkv": "2.12.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "4.0.2",
@@ -86,7 +86,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.1.4",
"@biomejs/biome": "^2.2.0",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",
@@ -1638,7 +1638,7 @@
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
"react-native-mmkv": ["react-native-mmkv@3.3.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-2iPjIJ+IAODXN35wm53EN6nv+hR/0NXLLiLWOdPA/0gXDB2dYVrr6MqvoiFpxhLhdj0B8fkf5ARNVavJaSvuuQ=="],
"react-native-mmkv": ["react-native-mmkv@2.12.2", "", { "peerDependencies": { "react": "*", "react-native": ">=0.71.0" } }, "sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],

View File

@@ -106,20 +106,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
// Initialize selectedOptions with default values
useEffect(() => {
if (itemsNotDownloaded.length === 1) {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}
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-4' />
<ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector

View File

@@ -1,5 +1,5 @@
import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native";
import { Text } from "./Text";
@@ -19,64 +19,59 @@ interface HorizontalScrollProps<T>
keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
extraData?: any;
noItemsText?: string;
}
export const HorizontalScroll = forwardRef<
HorizontalScrollRef,
HorizontalScrollProps<any>
>(
<T,>(
{
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
extraData,
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>,
) => {
const flashListRef = useRef<FlashList<T>>(null);
export const HorizontalScroll = <T,>(
props: HorizontalScrollProps<T> & {
ref?: React.ForwardedRef<HorizontalScrollRef>;
},
) => {
const {
data = [],
keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
loading = false,
height = 164,
extraData,
noItemsText,
ref,
...restProps
} = props;
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
const flashListRef = useRef<FlashList<T>>(null);
const renderFlashListItem = ({
item,
index,
}: {
item: T;
index: number;
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
if (!data || loading) {
return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
<View className='mr-2'>{renderItem(item, index)}</View>
);
if (!data || loading) {
return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
return (
<View style={[{ height }, containerStyle]}>
<FlashList<T>
ref={flashListRef}
data={data}
@@ -97,8 +92,8 @@ export const HorizontalScroll = forwardRef<
</Text>
</View>
)}
{...props}
{...restProps}
/>
);
},
);
</View>
);
};

View File

@@ -157,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className='ml-auto'
className='ml-auto p-2 rounded-full'
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size='small' color='white' />

View File

@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -21,10 +20,12 @@ export const StorageSettings = () => {
queryFn: async () => {
const app = await appSizeUsage();
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return { app, remaining, total, used: (total - remaining) / total };
return {
appSize: app.appSize,
total: app.total,
remaining: app.remaining,
used: (app.total - app.remaining) / app.total,
};
},
});
@@ -39,6 +40,7 @@ export const StorageSettings = () => {
};
const calculatePercentage = (value: number, total: number) => {
console.log("usage", value, total);
return ((value / total) * 100).toFixed(2);
};
@@ -61,13 +63,13 @@ export const StorageSettings = () => {
<View className='flex flex-row'>
<View
style={{
width: `${(size.app / size.total) * 100}%`,
width: `${(size.appSize / size.total) * 100}%`,
backgroundColor: Colors.primaryRGB,
}}
/>
<View
style={{
width: `${((size.total - size.remaining - size.app) / size.total) * 100}%`,
width: `${((size.total - size.remaining - size.appSize) / size.total) * 100}%`,
backgroundColor: Colors.primaryLightRGB,
}}
/>
@@ -81,7 +83,7 @@ export const StorageSettings = () => {
<View className='w-3 h-3 rounded-full bg-purple-600 mr-1' />
<Text className='text-white text-xs'>
{t("home.settings.storage.app_usage", {
usedSpace: calculatePercentage(size.app, size.total),
usedSpace: calculatePercentage(size.appSize, size.total),
})}
</Text>
</View>
@@ -90,7 +92,7 @@ export const StorageSettings = () => {
<Text className='text-white text-xs'>
{t("home.settings.storage.device_usage", {
availableSpace: calculatePercentage(
size.total - size.remaining - size.app,
size.total - size.remaining - size.appSize,
size.total,
),
})}
@@ -100,13 +102,15 @@ export const StorageSettings = () => {
)}
</View>
</View>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
{!Platform.isTV && (
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteClicked}
title={t("home.settings.storage.delete_all_downloaded_files")}
/>
</ListGroup>
)}
</View>
);
};

View File

@@ -113,7 +113,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
width: 130,
display: "flex",
flexDirection: "row",
justifyContent: "center",

View File

@@ -63,7 +63,7 @@ const BrightnessSlider = () => {
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
width: 130,
display: "flex",
flexDirection: "row",
justifyContent: "center",

View File

@@ -5,7 +5,6 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import {
type Dispatch,
@@ -41,10 +40,8 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
@@ -60,8 +57,13 @@ import { VideoProvider } from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import {
type AspectRatio,
AspectRatioSelector,
} from "./VideoScalingModeSelector";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
@@ -73,8 +75,7 @@ interface Props {
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: Dispatch<SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
@@ -90,6 +91,12 @@ interface Props {
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>;
aspectRatio?: AspectRatio;
scaleFactor?: ScaleFactor;
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
isVlc?: boolean;
}
@@ -109,8 +116,6 @@ export const Controls: FC<Props> = ({
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
@@ -118,13 +123,18 @@ export const Controls: FC<Props> = ({
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
setVideoAspectRatio,
setVideoScaleFactor,
aspectRatio = "default",
scaleFactor = 1.0,
setAspectRatio,
setScaleFactor,
offline = false,
isVlc = false,
}) => {
const [settings, updateSettings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [episodeView, setEpisodeView] = useState(false);
const [isSliding, setIsSliding] = useState(false);
@@ -272,18 +282,31 @@ export const Controls: FC<Props> = ({
const effectiveProgress = useSharedValue(0);
// Recompute progress whenever remote scrubbing is active
// Recompute progress whenever remote scrubbing is active or when progress significantly changes
useAnimatedReaction(
() => ({
isScrubbing: isRemoteScrubbing.value,
scrub: remoteScrubProgress.value,
actual: progress.value,
}),
(current) => {
effectiveProgress.value =
current.isScrubbing && current.scrub != null
? current.scrub
: current.actual;
(current, previous) => {
// Always update if scrubbing state changed or we're currently scrubbing
if (
current.isScrubbing !== previous?.isScrubbing ||
current.isScrubbing
) {
effectiveProgress.value =
current.isScrubbing && current.scrub != null
? current.scrub
: current.actual;
} else {
// When not scrubbing, only update if progress changed significantly (1 second)
const progressUnit = isVlc ? 1000 : 10000000; // 1 second in ms or ticks
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
if (progressDiff >= progressUnit) {
effectiveProgress.value = current.actual;
}
}
},
[],
);
@@ -346,7 +369,9 @@ export const Controls: FC<Props> = ({
previousIndexes,
mediaSource ?? undefined,
);
const queryParams = new URLSearchParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
@@ -438,24 +463,8 @@ export const Controls: FC<Props> = ({
[goToNextItem],
);
const goToItem = useCallback(
async (itemId: string) => {
if (offline) {
const queryParams = new URLSearchParams({
itemId: itemId,
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
const gotoItem = await getItemById(api, itemId);
if (!gotoItem) return;
goToItemCommon(gotoItem);
},
[goToItemCommon, api],
);
const lastCurrentTimeRef = useRef(0);
const lastRemainingTimeRef = useRef(0);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
@@ -464,8 +473,25 @@ export const Controls: FC<Props> = ({
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
setCurrentTime(current);
setRemainingTime(remaining);
// Only update state if the displayed time actually changed (avoid sub-second updates)
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
const lastCurrentSeconds = Math.floor(
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
);
const lastRemainingSeconds = Math.floor(
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
);
if (
currentSeconds !== lastCurrentSeconds ||
remainingSeconds !== lastRemainingSeconds
) {
setCurrentTime(current);
setRemainingTime(remaining);
lastCurrentTimeRef.current = current;
lastRemainingTimeRef.current = remaining;
}
},
[goToNextItem, isVlc],
);
@@ -519,11 +545,23 @@ export const Controls: FC<Props> = ({
isSeeking.value = true;
}, [showControls, isPlaying, pause]);
const handleTouchStart = useCallback(() => {
if (!showControls) {
return;
}
}, [showControls]);
const handleTouchEnd = useCallback(() => {
if (!showControls) {
return;
}
}, [showControls, isSliding]);
const handleSliderComplete = useCallback(
async (value: number) => {
setIsSliding(false);
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current) {
play();
@@ -626,10 +664,26 @@ export const Controls: FC<Props> = ({
}
}, [settings, isPlaying, isVlc, play, seek]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
lightHapticFeedback();
}, []);
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],
);
const switchOnEpisodeMode = useCallback(() => {
setEpisodeView(true);
@@ -715,7 +769,7 @@ export const Controls: FC<Props> = ({
<EpisodeList
item={item}
close={() => setEpisodeView(false)}
goToItem={goToItem}
goToItem={goToItemCommon}
/>
) : (
<>
@@ -796,17 +850,17 @@ export const Controls: FC<Props> = ({
<Ionicons name='play-skip-forward' size={24} color='white' />
</TouchableOpacity>
)}
{/* {mediaSource?.TranscodingUrl && ( */}
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color='white'
/>
</TouchableOpacity>
{/* Video Controls */}
<AspectRatioSelector
currentRatio={aspectRatio}
onRatioChange={handleAspectRatioChange}
disabled={!setVideoAspectRatio}
/>
<ScaleFactorSelector
currentScale={scaleFactor}
onScaleChange={handleScaleFactorChange}
disabled={!setVideoScaleFactor}
/>
<TouchableOpacity
onPress={onClose}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
@@ -942,7 +996,9 @@ export const Controls: FC<Props> = ({
position: "absolute",
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
bottom: settings?.safeAreaInControlsEnabled
? Math.max(insets.bottom - 17, 0)
: 0,
},
]}
className={"flex flex-col px-2"}
@@ -1014,39 +1070,67 @@ export const Controls: FC<Props> = ({
pointerEvents={showControls ? "box-none" : "none"}
>
<View className={"flex flex-col w-full shrink"}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
<View
style={{
height: 10,
justifyContent: "center",
alignItems: "stretch",
}}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() =>
(isSliding || showRemoteBubble) && memoizedRenderBubble()
}
sliderHeight={10}
thumbWidth={0}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() =>
(isSliding || showRemoteBubble) && memoizedRenderBubble()
}
sliderHeight={10}
thumbWidth={0}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>
</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>
<Text className='text-[12px] text-neutral-400'>
-{formatTimeString(remainingTime, 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 {(() => {
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,
});
})()}
</Text>
</View>
</View>
</View>
</View>

View File

@@ -27,7 +27,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
type Props = {
item: BaseItemDto;
close: () => void;
goToItem: (itemId: string) => Promise<void>;
goToItem: (item: BaseItemDto) => void;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
@@ -221,23 +221,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
ref={scrollViewRef}
data={episodes}
extraData={item}
renderItem={(_item, _idx) => (
// Note otherItem is the item that is being rendered, not the item that is currently selected
renderItem={(otherItem, _idx) => (
<View
key={_item.Id}
key={otherItem.Id}
style={{}}
className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : ""
item.Id !== otherItem.Id ? "opacity-75" : ""
}`}
>
<TouchableOpacity
onPress={() => {
goToItem(_item.Id);
goToItem(otherItem);
}}
>
<ContinueWatchingPoster
item={_item}
item={otherItem}
useEpisodePoster
showPlayButton={_item.Id !== item.Id}
showPlayButton={otherItem.Id !== item.Id}
/>
</TouchableOpacity>
<View className='shrink'>
@@ -248,20 +249,20 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
height: 36, // lineHeight * 2 for consistent two-line space
}}
>
{_item.Name}
{otherItem.Name}
</Text>
<Text numberOfLines={1} className='text-xs text-neutral-475'>
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
{`S${otherItem.ParentIndexNumber?.toString()}:E${otherItem.IndexNumber?.toString()}`}
</Text>
<Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(_item.RunTimeTicks)}
{runtimeTicksToSeconds(otherItem.RunTimeTicks)}
</Text>
</View>
<Text
numberOfLines={5}
numberOfLines={7}
className='text-xs text-neutral-500 shrink'
>
{_item.Overview}
{otherItem.Overview}
</Text>
</View>
)}

View File

@@ -0,0 +1,135 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Platform, TouchableOpacity } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
export type ScaleFactor =
| 1.0
| 1.1
| 1.2
| 1.3
| 1.4
| 1.5
| 1.6
| 1.7
| 1.8
| 1.9
| 2.0;
interface ScaleFactorSelectorProps {
currentScale: ScaleFactor;
onScaleChange: (scale: ScaleFactor) => void;
disabled?: boolean;
}
interface ScaleFactorOption {
id: ScaleFactor;
label: string;
description: string;
}
const SCALE_FACTOR_OPTIONS: ScaleFactorOption[] = [
{
id: 1.0,
label: "1.0x",
description: "Original size",
},
{
id: 1.1,
label: "1.1x",
description: "10% larger",
},
{
id: 1.2,
label: "1.2x",
description: "20% larger",
},
{
id: 1.3,
label: "1.3x",
description: "30% larger",
},
{
id: 1.4,
label: "1.4x",
description: "40% larger",
},
{
id: 1.5,
label: "1.5x",
description: "50% larger",
},
{
id: 1.6,
label: "1.6x",
description: "60% larger",
},
{
id: 1.7,
label: "1.7x",
description: "70% larger",
},
{
id: 1.8,
label: "1.8x",
description: "80% larger",
},
{
id: 1.9,
label: "1.9x",
description: "90% larger",
},
{
id: 2.0,
label: "2.0x",
description: "Double size",
},
];
export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
currentScale,
onScaleChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
// Hide on TV platforms since zeego doesn't support TV
if (Platform.isTV || !DropdownMenu) return null;
const handleScaleSelect = (scale: ScaleFactor) => {
onScaleChange(scale);
lightHapticFeedback();
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='search-outline' size={24} color='white' />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Scale Factor</DropdownMenu.Label>
<DropdownMenu.Separator />
{SCALE_FACTOR_OPTIONS.map((option) => (
<DropdownMenu.CheckboxItem
key={option.id}
value={currentScale === option.id ? "on" : "off"}
onValueChange={() => handleScaleSelect(option.id)}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,97 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Platform, TouchableOpacity } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
interface AspectRatioSelectorProps {
currentRatio: AspectRatio;
onRatioChange: (ratio: AspectRatio) => void;
disabled?: boolean;
}
interface AspectRatioOption {
id: AspectRatio;
label: string;
description: string;
}
const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
{
id: "default",
label: "Original",
description: "Use video's original aspect ratio",
},
{
id: "16:9",
label: "16:9",
description: "Widescreen (most common)",
},
{
id: "4:3",
label: "4:3",
description: "Traditional TV format",
},
{
id: "1:1",
label: "1:1",
description: "Square format",
},
{
id: "21:9",
label: "21:9",
description: "Ultra-wide cinematic",
},
];
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
currentRatio,
onRatioChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
// Hide on TV platforms since zeego doesn't support TV
if (Platform.isTV || !DropdownMenu) return null;
const handleRatioSelect = (ratio: AspectRatio) => {
onRatioChange(ratio);
lightHapticFeedback();
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='crop-outline' size={24} color='white' />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Aspect Ratio</DropdownMenu.Label>
<DropdownMenu.Separator />
{ASPECT_RATIO_OPTIONS.map((option) => (
<DropdownMenu.CheckboxItem
key={option.id}
value={currentRatio === option.id ? "on" : "off"}
onValueChange={() => handleRatioSelect(option.id)}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
<DropdownMenu.ItemSubtitle>
{option.description}
</DropdownMenu.ItemSubtitle>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

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

View File

@@ -70,7 +70,7 @@ export const usePlaybackManager = ({
useDownload();
/** Whether the device is online. actually it's connected to the internet. */
const isOnline = netInfo.isConnected;
const isOnline = useMemo(() => netInfo.isConnected, [netInfo.isConnected]);
// Adjacent episodes logic
const { data: adjacentItems } = useQuery({
@@ -152,22 +152,37 @@ export const usePlaybackManager = ({
// Handle local state update for downloaded items
if (localItem) {
const runTimeTicks = localItem.item.RunTimeTicks ?? 0;
const playedPercentage =
runTimeTicks > 0 ? (positionTicks / runTimeTicks) * 100 : 0;
// Jellyfin thresholds
const MINIMUM_PERCENTAGE = 5; // 5% minimum to save progress
const PLAYED_THRESHOLD_PERCENTAGE = 90; // 90% to mark as played
const isItemConsideredPlayed =
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90;
playedPercentage > PLAYED_THRESHOLD_PERCENTAGE;
const meetsMinimumPercentage = playedPercentage >= MINIMUM_PERCENTAGE;
const shouldSaveProgress =
meetsMinimumPercentage && !isItemConsideredPlayed;
updateDownloadedItem(itemId, {
...localItem,
item: {
...localItem.item,
UserData: {
...localItem.item.UserData,
PlaybackPositionTicks: isItemConsideredPlayed
? 0
: Math.floor(positionTicks),
PlaybackPositionTicks:
isItemConsideredPlayed || !shouldSaveProgress
? 0
: Math.floor(positionTicks),
Played: isItemConsideredPlayed,
LastPlayedDate: new Date().toISOString(),
PlayedPercentage: isItemConsideredPlayed
? 0
: (positionTicks / localItem.item.RunTimeTicks!) * 100,
PlayedPercentage:
isItemConsideredPlayed || !shouldSaveProgress
? 0
: playedPercentage,
},
},
});

View File

@@ -92,7 +92,9 @@ export interface VlcPlayerViewRef {
nextChapter: () => Promise<void>;
previousChapter: () => Promise<void>;
getChapters: () => Promise<ChapterInfo[] | null>;
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
getVideoCropGeometry: () => Promise<string | null>;
setSubtitleURL: (url: string) => Promise<void>;
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
}

View File

@@ -86,6 +86,12 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
setSubtitleURL: async (url: string) => {
await nativeRef.current?.setSubtitleURL(url);
},
setVideoAspectRatio: async (aspectRatio: string | null) => {
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
},
setVideoScaleFactor: async (scaleFactor: number) => {
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
},
}));
const {

View File

@@ -82,6 +82,14 @@ class VlcPlayerModule : Module() {
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
view.setSubtitleURL(url, name)
}
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
view.setVideoScaleFactor(scaleFactor)
}
}
}
}

View File

@@ -335,6 +335,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
}
fun setVideoAspectRatio(aspectRatio: String?) {
log.debug("Setting video aspect ratio: $aspectRatio")
mediaPlayer?.aspectRatio = aspectRatio
}
fun setVideoScaleFactor(scaleFactor: Float) {
log.debug("Setting video scale factor: $scaleFactor")
mediaPlayer?.scale = scaleFactor
}
private fun setInitialExternalSubtitles() {
externalSubtitles?.let { subtitles ->
for (subtitle in subtitles) {

View File

@@ -62,6 +62,14 @@ public class VlcPlayerModule: Module {
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
view.setVideoAspectRatio(aspectRatio)
}
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
view.setVideoScaleFactor(scaleFactor)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
return view.getSubtitleTracks()
}

View File

@@ -243,6 +243,26 @@ class VlcPlayerView: ExpoView {
return tracks
}
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
DispatchQueue.main.async {
if let aspectRatio = aspectRatio {
// Convert String to C string for VLC
let cString = strdup(aspectRatio)
self.mediaPlayer?.videoAspectRatio = cString
} else {
// Reset to default (let VLC determine aspect ratio)
self.mediaPlayer?.videoAspectRatio = nil
}
}
}
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
DispatchQueue.main.async {
self.mediaPlayer?.scaleFactor = scaleFactor
print("Set video scale factor: \(scaleFactor)")
}
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()

View File

@@ -80,7 +80,7 @@
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.8",
"react-native-mmkv": "3.3.0",
"react-native-mmkv": "2.12.2",
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "4.0.2",
@@ -101,7 +101,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.1.4",
"@biomejs/biome": "^2.2.0",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",
@@ -120,7 +120,8 @@
"exclude": [
"react-native",
"@shopify/flash-list",
"react-native-reanimated"
"react-native-reanimated",
"react-native-pager-view"
]
},
"doctor": {

View File

@@ -687,7 +687,7 @@ function useDownloadProvider() {
appSize += fileInfo.size;
}
}
return { total, remaining, app: appSize };
return { total, remaining, appSize: appSize };
};
return {
@@ -731,7 +731,7 @@ export function useDownload() {
APP_CACHE_DOWNLOAD_DIRECTORY: "",
cleanCacheDirectory: async () => {},
updateDownloadedItem: () => {},
appSizeUsage: async () => ({ total: 0, remaining: 0, app: 0 }),
appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }),
};
}

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.30.0" },
clientInfo: { name: "Streamyfin", version: "0.32.1" },
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.30.0"`,
}, DeviceId="${deviceId}", Version="0.32.1"`,
};
}, [deviceId]);

View File

@@ -5,6 +5,7 @@ export const formatBitrate = (bitrate?: number | null) => {
if (bitrate === 0) return "0 bps";
const i = Number.parseInt(
Math.floor(Math.log(bitrate) / Math.log(1000)).toString(),
10,
);
return `${Math.round((bitrate / 1000 ** i) * 100) / 100} ${sizes[i]}`;
};

View File

@@ -15,7 +15,7 @@ export default {
CodecProfiles: [
{
Type: MediaTypes.Video,
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
Codec: "h265,hevc,h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
},
{
Type: MediaTypes.Audio,
@@ -43,7 +43,7 @@ export default {
Context: "Streaming",
Protocol: "hls",
Container: "ts",
VideoCodec: "h264, hevc",
VideoCodec: "hevc, h264",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,