mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
24 Commits
renovate/r
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 | ||
|
|
1cb28788d6 | ||
|
|
ff9f855d4c | ||
|
|
13df2d1077 | ||
|
|
8389404975 | ||
|
|
cd920e2d84 | ||
|
|
92a11c18e0 | ||
|
|
e05f10fe42 | ||
|
|
2540ae22ce | ||
|
|
f490957091 | ||
|
|
a146fc8810 | ||
|
|
100d7e0830 | ||
|
|
ebcdd5bbf7 | ||
|
|
18b33884e6 | ||
|
|
9410239c48 | ||
|
|
4fed25a3ab | ||
|
|
a8810cae8a | ||
|
|
aff009de92 | ||
|
|
1924efbef2 | ||
|
|
24d006742b | ||
|
|
c34c7fbe83 |
10
.github/workflows/build-ios.yml
vendored
10
.github/workflows/build-ios.yml
vendored
@@ -58,13 +58,21 @@ jobs:
|
|||||||
else
|
else
|
||||||
bun run prebuild
|
bun run prebuild
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
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
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'javascript-typescript' ]
|
language: [ 'javascript-typescript', 'actions' ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2 # v3.29.9
|
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
|||||||
4
.github/workflows/linting.yml
vendored
4
.github/workflows/linting.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
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
|
id: lint_pr_title
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
deny-licenses: GPL-3.0, AGPL-3.0
|
deny-licenses: GPL-3.0, AGPL-3.0
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -181,6 +181,12 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
<br /><sub><b>@topiga</b></sub>
|
<br /><sub><b>@topiga</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
@@ -213,6 +219,12 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.30.0",
|
"version": "0.32.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 57,
|
"versionCode": 62,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
|||||||
@@ -39,26 +39,44 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [getDownloadedItems]);
|
}, [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 =
|
const seasonIndex =
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
episodeSeasonIndex ||
|
episodeSeasonIndex ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||||
|
}, [seasonGroups, seasonIndex]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -102,7 +120,7 @@ export default function page() {
|
|||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||||
<SeasonDropdown
|
<SeasonDropdown
|
||||||
item={series[0].item}
|
item={series[0].item}
|
||||||
seasons={series.map((s) => s.item)}
|
seasons={uniqueSeasons}
|
||||||
state={seasonIndexState}
|
state={seasonIndexState}
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
onSelect={(season) => {
|
onSelect={(season) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useNavigation, useRouter } from "expo-router";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -73,13 +73,13 @@ export default function settings() {
|
|||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
<DownloadSettings />
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|
||||||
<ChromecastSettings />
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -112,7 +112,7 @@ export default function settings() {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<StorageSettings />
|
{!Platform.isTV && <StorageSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -28,10 +28,12 @@ export default function page() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [state, setState] = useState<Record<string, boolean>>({});
|
const [state, setState] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||||
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||||
|
|
||||||
|
const _orderId = useId();
|
||||||
|
const _levelsId = useId();
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
logs
|
logs
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -38,12 +38,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
|
|
||||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
@@ -53,11 +50,12 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
// Load persisted state from storage
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
>("default");
|
||||||
return saved ?? false;
|
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 [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
@@ -82,11 +80,6 @@ export default function page() {
|
|||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist ignoreSafeAreas state whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
|
||||||
}, [ignoreSafeAreas]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -105,8 +98,8 @@ export default function page() {
|
|||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [_settings] = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
|
|
||||||
@@ -287,11 +280,15 @@ export default function page() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
|
// Update URL with final playback position before stopping
|
||||||
|
router.setParams({
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped, progress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
@@ -300,7 +297,7 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = () => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -316,7 +313,32 @@ export default function page() {
|
|||||||
repeatMode: RepeatMode.RepeatNone,
|
repeatMode: RepeatMode.RepeatNone,
|
||||||
playbackOrder: PlaybackOrder.Default,
|
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(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
@@ -329,10 +351,20 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update the playback position in the URL.
|
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||||
router.setParams({
|
const now = Date.now();
|
||||||
playbackPosition: msToTicks(currentTime).toString(),
|
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;
|
if (!item?.Id) return;
|
||||||
|
|
||||||
@@ -405,6 +437,7 @@ export default function page() {
|
|||||||
console.error("Error toggling mute:", error);
|
console.error("Error toggling mute:", error);
|
||||||
}
|
}
|
||||||
}, [previousVolume]);
|
}, [previousVolume]);
|
||||||
|
|
||||||
const volumeDownCb = useCallback(async () => {
|
const volumeDownCb = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -519,7 +552,7 @@ export default function page() {
|
|||||||
/** Whether the stream we're playing is not transcoding*/
|
/** Whether the stream we're playing is not transcoding*/
|
||||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
/** The initial options to pass to the VLC Player */
|
/** The initial options to pass to the VLC Player */
|
||||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
const initOptions = [``];
|
||||||
if (
|
if (
|
||||||
chosenSubtitleTrack &&
|
chosenSubtitleTrack &&
|
||||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||||
@@ -544,6 +577,54 @@ export default function page() {
|
|||||||
return () => setIsMounted(false);
|
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/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -571,7 +652,14 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "black",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -580,8 +668,6 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
|
||||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
@@ -625,20 +711,24 @@ export default function page() {
|
|||||||
isBuffering={isBuffering}
|
isBuffering={isBuffering}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
startPictureInPicture={videoRef.current?.startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={play}
|
||||||
pause={videoRef.current?.pause}
|
pause={pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={seek}
|
||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
offline={offline}
|
offline={offline}
|
||||||
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current?.setSubtitleURL}
|
setSubtitleURL={setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current?.setAudioTrack}
|
setAudioTrack={setAudioTrack}
|
||||||
|
setVideoAspectRatio={setVideoAspectRatio}
|
||||||
|
setVideoScaleFactor={setVideoScaleFactor}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
scaleFactor={scaleFactor}
|
||||||
|
setAspectRatio={setAspectRatio}
|
||||||
|
setScaleFactor={setScaleFactor}
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
14
biome.json
14
biome.json
@@ -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": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
"!node_modules/**",
|
"!node_modules",
|
||||||
"!ios/**",
|
"!ios",
|
||||||
"!android/**",
|
"!android",
|
||||||
"!Streamyfin.app/**",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr/**",
|
"!utils/jellyseerr",
|
||||||
"!.expo/**"
|
"!.expo"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
2
bun.lock
2
bun.lock
@@ -86,7 +86,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@biomejs/biome": "^2.1.4",
|
"@biomejs/biome": "^2.2.0",
|
||||||
"@react-native-community/cli": "^20.0.0",
|
"@react-native-community/cli": "^20.0.0",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
|
|||||||
@@ -106,20 +106,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
// Initialize selectedOptions with default values
|
// Initialize selectedOptions with default values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (itemsNotDownloaded.length === 1) {
|
setSelectedOptions(() => ({
|
||||||
setSelectedOptions(() => ({
|
bitrate: defaultBitrate,
|
||||||
bitrate: defaultBitrate,
|
mediaSource: defaultMediaSource,
|
||||||
mediaSource: defaultMediaSource,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
audioIndex: defaultAudioIndex,
|
||||||
audioIndex: defaultAudioIndex,
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
itemsNotDownloaded.length,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const itemsToDownload = useMemo(() => {
|
const itemsToDownload = useMemo(() => {
|
||||||
|
|||||||
@@ -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 bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 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 && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FlashList, type FlashListProps } from "@shopify/flash-list";
|
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 { View, type ViewStyle } from "react-native";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
@@ -19,64 +19,59 @@ interface HorizontalScrollProps<T>
|
|||||||
keyExtractor?: (item: T, index: number) => string;
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
contentContainerStyle?: ViewStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
loadingContainerStyle?: ViewStyle;
|
|
||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
extraData?: any;
|
extraData?: any;
|
||||||
noItemsText?: string;
|
noItemsText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HorizontalScroll = forwardRef<
|
export const HorizontalScroll = <T,>(
|
||||||
HorizontalScrollRef,
|
props: HorizontalScrollProps<T> & {
|
||||||
HorizontalScrollProps<any>
|
ref?: React.ForwardedRef<HorizontalScrollRef>;
|
||||||
>(
|
},
|
||||||
<T,>(
|
) => {
|
||||||
{
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
renderItem,
|
renderItem,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
loadingContainerStyle,
|
loading = false,
|
||||||
loading = false,
|
height = 164,
|
||||||
height = 164,
|
extraData,
|
||||||
extraData,
|
noItemsText,
|
||||||
noItemsText,
|
ref,
|
||||||
...props
|
...restProps
|
||||||
}: HorizontalScrollProps<T>,
|
} = props;
|
||||||
ref: React.ForwardedRef<HorizontalScrollRef>,
|
|
||||||
) => {
|
|
||||||
const flashListRef = useRef<FlashList<T>>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
const flashListRef = useRef<FlashList<T>>(null);
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
|
||||||
flashListRef.current?.scrollToIndex({
|
|
||||||
index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0,
|
|
||||||
viewOffset,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderFlashListItem = ({
|
useImperativeHandle(ref!, () => ({
|
||||||
item,
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
index,
|
flashListRef.current?.scrollToIndex({
|
||||||
}: {
|
index,
|
||||||
item: T;
|
animated: true,
|
||||||
index: number;
|
viewPosition: 0,
|
||||||
}) => <View className='mr-2'>{renderItem(item, index)}</View>;
|
viewOffset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (!data || loading) {
|
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
||||||
return (
|
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!data || loading) {
|
||||||
return (
|
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>
|
<FlashList<T>
|
||||||
ref={flashListRef}
|
ref={flashListRef}
|
||||||
data={data}
|
data={data}
|
||||||
@@ -97,8 +92,8 @@ export const HorizontalScroll = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
</View>
|
||||||
},
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={cancelJobMutation.isPending}
|
disabled={cancelJobMutation.isPending}
|
||||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||||
className='ml-auto'
|
className='ml-auto p-2 rounded-full'
|
||||||
>
|
>
|
||||||
{cancelJobMutation.isPending ? (
|
{cancelJobMutation.isPending ? (
|
||||||
<ActivityIndicator size='small' color='white' />
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -21,10 +20,12 @@ export const StorageSettings = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const app = await appSizeUsage();
|
const app = await appSizeUsage();
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
return {
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
appSize: app.appSize,
|
||||||
|
total: app.total,
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
remaining: app.remaining,
|
||||||
|
used: (app.total - app.remaining) / app.total,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export const StorageSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
console.log("usage", value, total);
|
||||||
return ((value / total) * 100).toFixed(2);
|
return ((value / total) * 100).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,13 +63,13 @@ export const StorageSettings = () => {
|
|||||||
<View className='flex flex-row'>
|
<View className='flex flex-row'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${(size.app / size.total) * 100}%`,
|
width: `${(size.appSize / size.total) * 100}%`,
|
||||||
backgroundColor: Colors.primaryRGB,
|
backgroundColor: Colors.primaryRGB,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: `${((size.total - size.remaining - size.app) / size.total) * 100}%`,
|
width: `${((size.total - size.remaining - size.appSize) / size.total) * 100}%`,
|
||||||
backgroundColor: Colors.primaryLightRGB,
|
backgroundColor: Colors.primaryLightRGB,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -81,7 +83,7 @@ export const StorageSettings = () => {
|
|||||||
<View className='w-3 h-3 rounded-full bg-purple-600 mr-1' />
|
<View className='w-3 h-3 rounded-full bg-purple-600 mr-1' />
|
||||||
<Text className='text-white text-xs'>
|
<Text className='text-white text-xs'>
|
||||||
{t("home.settings.storage.app_usage", {
|
{t("home.settings.storage.app_usage", {
|
||||||
usedSpace: calculatePercentage(size.app, size.total),
|
usedSpace: calculatePercentage(size.appSize, size.total),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -90,7 +92,7 @@ export const StorageSettings = () => {
|
|||||||
<Text className='text-white text-xs'>
|
<Text className='text-white text-xs'>
|
||||||
{t("home.settings.storage.device_usage", {
|
{t("home.settings.storage.device_usage", {
|
||||||
availableSpace: calculatePercentage(
|
availableSpace: calculatePercentage(
|
||||||
size.total - size.remaining - size.app,
|
size.total - size.remaining - size.appSize,
|
||||||
size.total,
|
size.total,
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
@@ -100,13 +102,15 @@ export const StorageSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ListGroup>
|
{!Platform.isTV && (
|
||||||
<ListItem
|
<ListGroup>
|
||||||
textColor='red'
|
<ListItem
|
||||||
onPress={onDeleteClicked}
|
textColor='red'
|
||||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
onPress={onDeleteClicked}
|
||||||
/>
|
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||||
</ListGroup>
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
sliderContainer: {
|
sliderContainer: {
|
||||||
width: 150,
|
width: 130,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const BrightnessSlider = () => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
sliderContainer: {
|
sliderContainer: {
|
||||||
width: 150,
|
width: 130,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
@@ -41,10 +40,8 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
formatTimeString,
|
formatTimeString,
|
||||||
@@ -60,8 +57,13 @@ import { VideoProvider } from "./contexts/VideoContext";
|
|||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
|
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
import {
|
||||||
|
type AspectRatio,
|
||||||
|
AspectRatioSelector,
|
||||||
|
} from "./VideoScalingModeSelector";
|
||||||
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -73,8 +75,7 @@ interface Props {
|
|||||||
progress: SharedValue<number>;
|
progress: SharedValue<number>;
|
||||||
isBuffering: boolean;
|
isBuffering: boolean;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
ignoreSafeAreas?: boolean;
|
|
||||||
setIgnoreSafeAreas: Dispatch<SetStateAction<boolean>>;
|
|
||||||
enableTrickplay?: boolean;
|
enableTrickplay?: boolean;
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
setShowControls: (shown: boolean) => void;
|
setShowControls: (shown: boolean) => void;
|
||||||
@@ -90,6 +91,12 @@ interface Props {
|
|||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
setSubtitleURL?: (url: string, customName: string) => void;
|
||||||
setSubtitleTrack?: (index: number) => void;
|
setSubtitleTrack?: (index: number) => void;
|
||||||
setAudioTrack?: (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;
|
isVlc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,8 +116,6 @@ export const Controls: FC<Props> = ({
|
|||||||
cacheProgress,
|
cacheProgress,
|
||||||
showControls,
|
showControls,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
ignoreSafeAreas,
|
|
||||||
setIgnoreSafeAreas,
|
|
||||||
mediaSource,
|
mediaSource,
|
||||||
isVideoLoaded,
|
isVideoLoaded,
|
||||||
getAudioTracks,
|
getAudioTracks,
|
||||||
@@ -118,13 +123,18 @@ export const Controls: FC<Props> = ({
|
|||||||
setSubtitleURL,
|
setSubtitleURL,
|
||||||
setSubtitleTrack,
|
setSubtitleTrack,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
|
setVideoAspectRatio,
|
||||||
|
setVideoScaleFactor,
|
||||||
|
aspectRatio = "default",
|
||||||
|
scaleFactor = 1.0,
|
||||||
|
setAspectRatio,
|
||||||
|
setScaleFactor,
|
||||||
offline = false,
|
offline = false,
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [isSliding, setIsSliding] = useState(false);
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
@@ -272,18 +282,31 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const effectiveProgress = useSharedValue(0);
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
// Recompute progress whenever remote scrubbing is active
|
// Recompute progress whenever remote scrubbing is active or when progress significantly changes
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => ({
|
() => ({
|
||||||
isScrubbing: isRemoteScrubbing.value,
|
isScrubbing: isRemoteScrubbing.value,
|
||||||
scrub: remoteScrubProgress.value,
|
scrub: remoteScrubProgress.value,
|
||||||
actual: progress.value,
|
actual: progress.value,
|
||||||
}),
|
}),
|
||||||
(current) => {
|
(current, previous) => {
|
||||||
effectiveProgress.value =
|
// Always update if scrubbing state changed or we're currently scrubbing
|
||||||
current.isScrubbing && current.scrub != null
|
if (
|
||||||
? current.scrub
|
current.isScrubbing !== previous?.isScrubbing ||
|
||||||
: current.actual;
|
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,
|
previousIndexes,
|
||||||
mediaSource ?? undefined,
|
mediaSource ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
|
...(offline && { offline: "true" }),
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
@@ -438,24 +463,8 @@ export const Controls: FC<Props> = ({
|
|||||||
[goToNextItem],
|
[goToNextItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToItem = useCallback(
|
const lastCurrentTimeRef = useRef(0);
|
||||||
async (itemId: string) => {
|
const lastRemainingTimeRef = useRef(0);
|
||||||
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 updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
@@ -464,8 +473,25 @@ export const Controls: FC<Props> = ({
|
|||||||
? maxValue - currentProgress
|
? maxValue - currentProgress
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
: ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
setCurrentTime(current);
|
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||||
setRemainingTime(remaining);
|
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],
|
[goToNextItem, isVlc],
|
||||||
);
|
);
|
||||||
@@ -519,11 +545,23 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking.value = true;
|
isSeeking.value = true;
|
||||||
}, [showControls, isPlaying, pause]);
|
}, [showControls, isPlaying, pause]);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback(() => {
|
||||||
|
if (!showControls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [showControls]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
if (!showControls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [showControls, isSliding]);
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
|
setIsSliding(false);
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
setIsSliding(false);
|
|
||||||
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
play();
|
play();
|
||||||
@@ -626,10 +664,26 @@ export const Controls: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc, play, seek]);
|
}, [settings, isPlaying, isVlc, play, seek]);
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const handleAspectRatioChange = useCallback(
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
async (newRatio: AspectRatio) => {
|
||||||
lightHapticFeedback();
|
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(() => {
|
const switchOnEpisodeMode = useCallback(() => {
|
||||||
setEpisodeView(true);
|
setEpisodeView(true);
|
||||||
@@ -715,7 +769,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
close={() => setEpisodeView(false)}
|
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' />
|
<Ionicons name='play-skip-forward' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{/* {mediaSource?.TranscodingUrl && ( */}
|
{/* Video Controls */}
|
||||||
<TouchableOpacity
|
<AspectRatioSelector
|
||||||
onPress={toggleIgnoreSafeAreas}
|
currentRatio={aspectRatio}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
onRatioChange={handleAspectRatioChange}
|
||||||
>
|
disabled={!setVideoAspectRatio}
|
||||||
<Ionicons
|
/>
|
||||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
<ScaleFactorSelector
|
||||||
size={24}
|
currentScale={scaleFactor}
|
||||||
color='white'
|
onScaleChange={handleScaleFactorChange}
|
||||||
/>
|
disabled={!setVideoScaleFactor}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
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",
|
position: "absolute",
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 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"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -1014,39 +1070,67 @@ export const Controls: FC<Props> = ({
|
|||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
<View className={"flex flex-col w-full shrink"}>
|
<View className={"flex flex-col w-full shrink"}>
|
||||||
<Slider
|
<View
|
||||||
theme={{
|
style={{
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
height: 10,
|
||||||
minimumTrackTintColor: "#fff",
|
justifyContent: "center",
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
alignItems: "stretch",
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#666",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
}}
|
}}
|
||||||
renderThumb={() => null}
|
onTouchStart={handleTouchStart}
|
||||||
cache={cacheProgress}
|
onTouchEnd={handleTouchEnd}
|
||||||
onSlidingStart={handleSliderStart}
|
>
|
||||||
onSlidingComplete={handleSliderComplete}
|
<Slider
|
||||||
onValueChange={handleSliderChange}
|
theme={{
|
||||||
containerStyle={{
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
borderRadius: 100,
|
minimumTrackTintColor: "#fff",
|
||||||
}}
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
renderBubble={() =>
|
bubbleBackgroundColor: "#fff",
|
||||||
(isSliding || showRemoteBubble) && memoizedRenderBubble()
|
bubbleTextColor: "#666",
|
||||||
}
|
heartbeatColor: "#999",
|
||||||
sliderHeight={10}
|
}}
|
||||||
thumbWidth={0}
|
renderThumb={() => null}
|
||||||
progress={effectiveProgress}
|
cache={cacheProgress}
|
||||||
minimumValue={min}
|
onSlidingStart={handleSliderStart}
|
||||||
maximumValue={max}
|
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'>
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[12px] text-neutral-400'>
|
<View className='flex flex-col items-end'>
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
<Text className='text-[12px] text-neutral-400'>
|
||||||
</Text>
|
-{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>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
|
|||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
goToItem: (itemId: string) => Promise<void>;
|
goToItem: (item: BaseItemDto) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
@@ -221,23 +221,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
data={episodes}
|
data={episodes}
|
||||||
extraData={item}
|
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
|
<View
|
||||||
key={_item.Id}
|
key={otherItem.Id}
|
||||||
style={{}}
|
style={{}}
|
||||||
className={`flex flex-col w-44 ${
|
className={`flex flex-col w-44 ${
|
||||||
item.Id !== _item.Id ? "opacity-75" : ""
|
item.Id !== otherItem.Id ? "opacity-75" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
goToItem(_item.Id);
|
goToItem(otherItem);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster
|
||||||
item={_item}
|
item={otherItem}
|
||||||
useEpisodePoster
|
useEpisodePoster
|
||||||
showPlayButton={_item.Id !== item.Id}
|
showPlayButton={otherItem.Id !== item.Id}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View className='shrink'>
|
<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
|
height: 36, // lineHeight * 2 for consistent two-line space
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{_item.Name}
|
{otherItem.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
<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>
|
||||||
<Text className='text-xs text-neutral-500'>
|
<Text className='text-xs text-neutral-500'>
|
||||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
{runtimeTicksToSeconds(otherItem.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={5}
|
numberOfLines={7}
|
||||||
className='text-xs text-neutral-500 shrink'
|
className='text-xs text-neutral-500 shrink'
|
||||||
>
|
>
|
||||||
{_item.Overview}
|
{otherItem.Overview}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
135
components/video-player/controls/ScaleFactorSelector.tsx
Normal file
135
components/video-player/controls/ScaleFactorSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
eas.json
6
eas.json
@@ -46,14 +46,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.30.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.30.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.30.0",
|
"channel": "0.32.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const usePlaybackManager = ({
|
|||||||
useDownload();
|
useDownload();
|
||||||
|
|
||||||
/** Whether the device is online. actually it's connected to the internet. */
|
/** 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
|
// Adjacent episodes logic
|
||||||
const { data: adjacentItems } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
@@ -152,22 +152,37 @@ export const usePlaybackManager = ({
|
|||||||
|
|
||||||
// Handle local state update for downloaded items
|
// Handle local state update for downloaded items
|
||||||
if (localItem) {
|
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 =
|
const isItemConsideredPlayed =
|
||||||
(localItem.item.UserData?.PlayedPercentage ?? 0) > 90;
|
playedPercentage > PLAYED_THRESHOLD_PERCENTAGE;
|
||||||
|
const meetsMinimumPercentage = playedPercentage >= MINIMUM_PERCENTAGE;
|
||||||
|
|
||||||
|
const shouldSaveProgress =
|
||||||
|
meetsMinimumPercentage && !isItemConsideredPlayed;
|
||||||
|
|
||||||
updateDownloadedItem(itemId, {
|
updateDownloadedItem(itemId, {
|
||||||
...localItem,
|
...localItem,
|
||||||
item: {
|
item: {
|
||||||
...localItem.item,
|
...localItem.item,
|
||||||
UserData: {
|
UserData: {
|
||||||
...localItem.item.UserData,
|
...localItem.item.UserData,
|
||||||
PlaybackPositionTicks: isItemConsideredPlayed
|
PlaybackPositionTicks:
|
||||||
? 0
|
isItemConsideredPlayed || !shouldSaveProgress
|
||||||
: Math.floor(positionTicks),
|
? 0
|
||||||
|
: Math.floor(positionTicks),
|
||||||
Played: isItemConsideredPlayed,
|
Played: isItemConsideredPlayed,
|
||||||
LastPlayedDate: new Date().toISOString(),
|
LastPlayedDate: new Date().toISOString(),
|
||||||
PlayedPercentage: isItemConsideredPlayed
|
PlayedPercentage:
|
||||||
? 0
|
isItemConsideredPlayed || !shouldSaveProgress
|
||||||
: (positionTicks / localItem.item.RunTimeTicks!) * 100,
|
? 0
|
||||||
|
: playedPercentage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ export interface VlcPlayerViewRef {
|
|||||||
nextChapter: () => Promise<void>;
|
nextChapter: () => Promise<void>;
|
||||||
previousChapter: () => Promise<void>;
|
previousChapter: () => Promise<void>;
|
||||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
|
||||||
getVideoCropGeometry: () => Promise<string | null>;
|
getVideoCropGeometry: () => Promise<string | null>;
|
||||||
setSubtitleURL: (url: string) => Promise<void>;
|
setSubtitleURL: (url: string) => Promise<void>;
|
||||||
|
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
|
||||||
|
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
setSubtitleURL: async (url: string) => {
|
setSubtitleURL: async (url: string) => {
|
||||||
await nativeRef.current?.setSubtitleURL(url);
|
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 {
|
const {
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ class VlcPlayerModule : Module() {
|
|||||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
||||||
view.setSubtitleURL(url, name)
|
view.setSubtitleURL(url, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
|
||||||
|
view.setVideoAspectRatio(aspectRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
|
||||||
|
view.setVideoScaleFactor(scaleFactor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +335,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
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() {
|
private fun setInitialExternalSubtitles() {
|
||||||
externalSubtitles?.let { subtitles ->
|
externalSubtitles?.let { subtitles ->
|
||||||
for (subtitle in subtitles) {
|
for (subtitle in subtitles) {
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ public class VlcPlayerModule: Module {
|
|||||||
view.setSubtitleTrack(trackIndex)
|
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
|
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||||
return view.getSubtitleTracks()
|
return view.getSubtitleTracks()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,26 @@ class VlcPlayerView: ExpoView {
|
|||||||
return tracks
|
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) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@biomejs/biome": "^2.1.4",
|
"@biomejs/biome": "^2.2.0",
|
||||||
"@react-native-community/cli": "^20.0.0",
|
"@react-native-community/cli": "^20.0.0",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "^0.1.1",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
@@ -120,7 +120,8 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"react-native",
|
"react-native",
|
||||||
"@shopify/flash-list",
|
"@shopify/flash-list",
|
||||||
"react-native-reanimated"
|
"react-native-reanimated",
|
||||||
|
"react-native-pager-view"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ function useDownloadProvider() {
|
|||||||
appSize += fileInfo.size;
|
appSize += fileInfo.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { total, remaining, app: appSize };
|
return { total, remaining, appSize: appSize };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -731,7 +731,7 @@ export function useDownload() {
|
|||||||
APP_CACHE_DOWNLOAD_DIRECTORY: "",
|
APP_CACHE_DOWNLOAD_DIRECTORY: "",
|
||||||
cleanCacheDirectory: async () => {},
|
cleanCacheDirectory: async () => {},
|
||||||
updateDownloadedItem: () => {},
|
updateDownloadedItem: () => {},
|
||||||
appSizeUsage: async () => ({ total: 0, remaining: 0, app: 0 }),
|
appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.30.0" },
|
clientInfo: { name: "Streamyfin", version: "0.32.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -93,7 +93,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.30.0"`,
|
}, DeviceId="${deviceId}", Version="0.32.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const formatBitrate = (bitrate?: number | null) => {
|
|||||||
if (bitrate === 0) return "0 bps";
|
if (bitrate === 0) return "0 bps";
|
||||||
const i = Number.parseInt(
|
const i = Number.parseInt(
|
||||||
Math.floor(Math.log(bitrate) / Math.log(1000)).toString(),
|
Math.floor(Math.log(bitrate) / Math.log(1000)).toString(),
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
return `${Math.round((bitrate / 1000 ** i) * 100) / 100} ${sizes[i]}`;
|
return `${Math.round((bitrate / 1000 ** i) * 100) / 100} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user