Compare commits

..

114 Commits

Author SHA1 Message Date
Fredrik Burmester
8b6c7a7603 fix: incorrect matrix 2024-12-09 15:53:53 +01:00
Fredrik Burmester
5a07eccd9b fix: remove black background in some logo images 2024-12-09 15:39:23 +01:00
Fredrik Burmester
4dff26e8c3 Merge pull request #270 from Alexk2309/change/default-subtitle-size-for-different-platforms
Changed default subtitle size depending platform
2024-12-09 15:04:30 +01:00
Alex Kim
ee2edda507 Changed default subtitle size depending platform 2024-12-10 01:03:09 +11:00
Fredrik Burmester
9e6a8424db Merge pull request #269 from Alexk2309/fix/next-up-episodes-not-showing-for-some-series
Fix for next-up not showing up for some episodes of a series.
2024-12-09 15:02:11 +01:00
Alex Kim
d37ecc1bef Added fix for change 2024-12-10 00:51:47 +11:00
Fredrik Burmester
e70fd3ee45 Merge pull request #268 from Alexk2309/fix/default-subtitles-not-showing
Fix default subtitles not working on app.
2024-12-09 08:55:56 +01:00
Alex Kim
16e93513e2 Fixed issue 2024-12-09 05:51:04 +11:00
Fredrik Burmester
b0c506f85d Merge pull request #267 from Alexk2309/hotfix/small-ui-changes
Changed trickplay debounce to 10ms and added padding for EpisodeList
2024-12-08 19:22:15 +01:00
Alex Kim
b762aff6e2 Changed trickplay debounce to 10ms and added padding for EpisodeList 2024-12-09 04:46:09 +11:00
Fredrik Burmester
75639c4424 Merge pull request #266 from Alexk2309/hotfix/bug-fixes-for-player
Hotfix/bug fixes for player
2024-12-08 18:14:17 +01:00
Fredrik Burmester
4606ce1834 chore: update deps 2024-12-08 18:13:57 +01:00
Alex Kim
44bde8f41e Fixed more bugs 2024-12-09 04:12:13 +11:00
Alex Kim
828edad749 Added padding on right side only 2024-12-09 04:07:56 +11:00
Alex Kim
f842c8a41f Episode list fix rendering 2024-12-09 04:01:59 +11:00
Alex Kim
4d38573973 Fixed rubber banding issue 2024-12-09 03:38:22 +11:00
Alex Kim
785e3b6859 Stop websocket on page exit for transcoded player 2024-12-09 02:56:27 +11:00
Alex Kim
40b3304f9b Fixed socket not closing on exit 2024-12-09 02:48:36 +11:00
Fredrik Burmester
abf1b343cd Merge pull request #265 from herrrta/fix/delete-type
Fix delete by show file type
2024-12-08 16:37:21 +01:00
herrrta
e427802aae Fix delete by show file type 2024-12-08 10:34:03 -05:00
Fredrik Burmester
684e671750 fix: design issues regarding downloads 2024-12-08 16:29:17 +01:00
Fredrik Burmester
5e9b28f2eb fix: type errors and design 2024-12-08 15:59:03 +01:00
Alex Kim
1d4c56265f Made sure changes are saved when changing episode list 2024-12-09 01:54:30 +11:00
Alex Kim
1102df8384 Added fixes for opacity style 2024-12-09 01:06:32 +11:00
Fredrik Burmester
15073f47db Merge pull request #264 from Alexk2309/feature/episode-list-in-player
Feature/episode list in player
2024-12-08 14:16:58 +01:00
Alex Kim
15f32bca6c Removed useless file 2024-12-09 00:13:41 +11:00
Alex Kim
108c5f9bab Merged websocket PR 2024-12-09 00:11:19 +11:00
Fredrik Burmester
24d781050f Merge pull request #263 from fredrikburmester/fix/global-websockets-with-vlc
fix: websockets now work globally with vlc and transcoded player
2024-12-08 14:02:13 +01:00
Alex Kim
353ebf3b0c Removed opacity for unselected items 2024-12-09 00:00:03 +11:00
Fredrik Burmester
c8b16f947d fix: increase max streaming bitrate for HUGE files 2024-12-08 13:59:36 +01:00
Fredrik Burmester
bd24f59199 fix: websockets now work globally with vlc and transcoded player
does not disconnect and reconnect every time you open and close the player
2024-12-08 13:59:16 +01:00
Alex Kim
a6b49c42cf Added style changes 2024-12-08 23:50:59 +11:00
Fredrik Burmester
5afb677b3a chore 2024-12-08 11:57:47 +01:00
Alex Kim
65d3da155f Fixed style issue for devices with bottom safe area 2024-12-08 18:34:20 +11:00
Alex Kim
d616574232 Added scroll to episode when going in player mode 2024-12-08 18:25:10 +11:00
Alex Kim
b8b083abe2 Added correct starting season index 2024-12-08 18:14:41 +11:00
Alex Kim
49a1bffcf5 Added style changes for episode list 2024-12-08 18:03:06 +11:00
Alex Kim
cb6c716830 Fixed playbutton showing up on current Episode 2024-12-08 17:26:48 +11:00
Alex Kim
a725af114c Fixed playbutton showing up on current Episode 2024-12-08 17:26:17 +11:00
Alex Kim
5b290fd667 Got season dropdown to start working 2024-12-08 17:18:44 +11:00
Alex Kim
de4f60f564 WIP 2024-12-08 07:44:35 +11:00
Alex Kim
a4cd3ea600 WIP 2024-12-08 07:15:34 +11:00
Fredrik Burmester
3db12bd76a Merge pull request #261 from Alexk2309/fix/refactor-vlc-media-player
Refactored perfomance change for IOS
2024-12-07 16:53:02 +01:00
Alex Kim
26305c2983 Used debouncing for trick play and stop rendering trickplay, while not sliding 2024-12-08 02:48:23 +11:00
Alex Kim
9c02fa2e72 Refactored perfomance change for IOS 2024-12-07 23:04:21 +11:00
Fredrik Burmester
b08ec474a4 Merge pull request #259 from herrrta/fix/remux-download
Fix queue being downloaded all at once
2024-12-07 09:28:49 +01:00
Fredrik Burmester
416fb24ac0 Merge pull request #257 from Alexk2309/fix/pause-video-when-exiting-ios
Made pause video on app exit and added some performance changes for android
2024-12-07 09:28:08 +01:00
Alex Kim
0d2b15e5af Removed unneccessary print statement 2024-12-07 18:17:28 +11:00
Alex Kim
ef036cb362 Fixed for android and added some peformance changes for android 2024-12-07 18:11:41 +11:00
herrrta
006e457d23 # Fix queue being downloaded all at once 2024-12-06 19:54:43 -05:00
Alex Kim
832a717585 Improve performance of android version 2024-12-07 06:35:25 +11:00
Alex Kim
39f86a9eb1 Added android fix 2024-12-07 06:01:04 +11:00
Alex Kim
38445c6959 Fixed issue for IOS and android 2024-12-07 05:41:46 +11:00
Alex Kim
24320541c7 Made pause video on app exit on IOS 2024-12-07 04:15:16 +11:00
Fredrik Burmester
ee4e9fe347 Merge pull request #256 from Alexk2309/fix/optimize-direct-video-player-ios
Optimized direct player for IOS
2024-12-06 17:28:43 +01:00
Alex Kim
6d43b34f66 Optimized direct player for IOS 2024-12-07 03:14:25 +11:00
retardgerman
63cf7eb622 specified versions in dropdown menu 2024-12-06 14:16:53 +01:00
Fredrik Burmester
32130f1a9c Merge pull request #255 from Alexk2309/feature/prefetch-trick-play-on-video-start
Prefetch trick-play images
2024-12-06 08:39:01 +01:00
Fredrik Burmester
7f458f2f0b Merge pull request #254 from Alexk2309/hotfix/fixed-bugs-for-new-controls-ui
Bug fixes for controls
2024-12-06 08:38:48 +01:00
Alex Kim
6ec6c6daa0 Added feature to prefetch trick-play images on video start rather than downloading it while scrubbing 2024-12-06 17:41:42 +11:00
Alex Kim
02a48fd958 Refactored code, so that way the skip intro button is not using absolute positioning 2024-12-06 16:42:58 +11:00
Alex Kim
04c4dfd13a Fixed bugs for skip intro button still being able to be clicked once it is gone past the time frame 2024-12-06 15:08:33 +11:00
retardgerman
40bdb10653 add dropdown menu for App version 2024-12-05 22:59:30 +01:00
Fredrik Burmester
f16c486bfb Merge pull request #252 from Alexk2309/hotfix/control-ui-fix-skip-intro-button-overlapping-with-safe-area
Fixed skip intro button overlapping with safe area
2024-12-05 20:35:27 +01:00
Alex Kim
19fc00e314 Fixed skip intro button overlapping with safe area 2024-12-06 06:14:53 +11:00
Fredrik Burmester
c51965016c Merge pull request #251 from Alexk2309/feature/intergration-for-ios-file-provider
File System Support
2024-12-05 19:28:27 +01:00
Alex Kim
3bcf73f0dd Changed app ios settings 2024-12-06 05:11:26 +11:00
retardgerman
1ecef4be67 Update bug_report.yml 2024-12-05 18:29:59 +01:00
Fredrik Burmester
387525f9c3 Merge pull request #249 from Alexk2309/feature/subtitle-size-change
Added the ability to change subtitle size
2024-12-05 18:10:46 +01:00
Fredrik Burmester
cf182d8473 Merge branch 'master' into feature/subtitle-size-change 2024-12-05 18:10:38 +01:00
Fredrik Burmester
f0e3321a16 Merge pull request #248 from Alexk2309/feature/control-ui-change
Feature/control UI change
2024-12-05 18:09:38 +01:00
Fredrik Burmester
96c76e2b08 Merge pull request #247 from herrrta/feat/multiple-remux-downloads
Multiple Remux downloads
2024-12-05 18:09:27 +01:00
retardgerman
aaa07d93cf better bug_report.yml 2024-12-05 17:52:20 +01:00
Alex Kim
0716bba6ec Updated setting description 2024-12-06 03:47:58 +11:00
retardgerman
15476f3686 correct bug_report.yml 2024-12-05 17:44:15 +01:00
Alex Kim
97cf9185d3 Added the ability to change subtitle size 2024-12-06 03:40:48 +11:00
retardgerman
c11ad17ca5 fixed bug_report.yml 2024-12-05 17:34:20 +01:00
retardgerman
b0d563bc48 new bug_report.yml 2024-12-05 17:25:55 +01:00
retardgerman
909fc84ec0 Update bug_report.yml 2024-12-05 17:15:36 +01:00
retardgerman
0400597061 finally fixed bug_report.yml 2024-12-05 17:13:01 +01:00
retardgerman
b44a5fbbba fixed bug_report.yml 2024-12-05 17:09:13 +01:00
retardgerman
a5f6ba27b1 reworked bug submit handling 2024-12-05 17:08:32 +01:00
Alex Kim
ece1b8f2b9 Removed consoled log for changing brightness 2024-12-06 03:02:11 +11:00
Alex Kim
beb6702112 merge with master 2024-12-06 02:53:28 +11:00
Alex Kim
98c0ed4ad5 Removed pink background from slider 2024-12-06 02:50:16 +11:00
Alex Kim
b3f471bfa6 Added brightness slider 2024-12-06 01:17:24 +11:00
herrrta
1a10f0debf # Multiple Remux downloads
- Added stepper component
- Disabled more download settings based on download method
- refactored useRemuxHlsToMp4.ts to allow for multiple remux downloads
2024-12-05 01:27:55 -05:00
Fredrik Burmester
ac266c6956 Merge pull request #243 from herrrta/feat/show-overall-download-size
Show app usage
2024-12-03 16:23:24 +01:00
herrrta
b23a50914c Show app usage
- Added app usage to settings
- add more readable size formats
2024-12-03 09:56:07 -05:00
Fredrik Burmester
5c4a419d22 Merge branch 'pr/242' 2024-12-03 14:53:27 +01:00
Fredrik Burmester
3d034864f9 chore 2024-12-03 11:59:25 +01:00
Fredrik Burmester
ea183c426b fix: play new item when pressing play button 2024-12-03 11:59:22 +01:00
Fredrik Burmester
92be991cf7 fix: subtitles burn in for chromecast 2024-12-03 11:59:13 +01:00
herrrta
b73c29221a New delete options & storage visibility
- Added react-native-progress dependency
- Added bottom sheet to downloads page to handle actions for deleting items by type
- Added ability to long press to delete a single series
- Added ability to delete by season
- Refactored delete helpers in DownloadProvider.tsx
- Display storage usage inside downloads & settings page
- Fixed Delete all downloaded files from delting user data in mmkv
2024-12-02 22:37:59 -05:00
Alex Kim
880a739dd4 Reworked controls to have pause, and skip not in the slider anymore 2024-12-03 03:56:55 +11:00
Fredrik Burmester
69ffdc2ddf fix: try to fix #239 2024-12-02 12:04:24 +01:00
Fredrik Burmester
d686bd8c7b fix: tab bar icon not hiding 2024-12-02 11:44:52 +01:00
Fredrik Burmester
c8a60e735b Merge branch 'pr/238' 2024-12-02 11:35:22 +01:00
Fredrik Burmester
05f7574e60 fix: hide tab bar icon 2024-12-02 11:34:57 +01:00
Fredrik Burmester
11b880863c Revert "feat: cache item size"
This reverts commit aec172d8f5.
2024-12-02 11:34:49 +01:00
Fredrik Burmester
aec172d8f5 feat: cache item size 2024-12-02 11:13:05 +01:00
herrrta
7b52528d72 # Persist DownloadedItem size when downloading or when reading file for the first time 2024-12-01 18:07:34 -05:00
Fredrik Burmester
5fd1d9080e chore 2024-12-01 23:08:21 +01:00
Fredrik Burmester
5cc0f381fa chore 2024-12-01 22:26:40 +01:00
Fredrik Burmester
0f547deb39 Merge branch 'pr/210' 2024-12-01 21:18:20 +01:00
Fredrik Burmester
5aeb80348a chore 2024-12-01 21:16:53 +01:00
Fredrik Burmester
1dfc0ac762 Merge branch 'pr/233' 2024-12-01 21:01:59 +01:00
Fredrik Burmester
2b8aee442a Merge branch 'pr/232' 2024-12-01 21:01:54 +01:00
Fredrik Burmester
3e45adfeb5 Merge branch 'pr/231' 2024-12-01 21:01:45 +01:00
herrrta
b41363d347 # Allow option for viewing custom menu links
- Added new 'Other' setting to toggle new tab visibility
- Added new Tab to show custom links
- Added icon asset for list
2024-12-01 14:26:49 -05:00
herrrta
2d5a27c015 # Add Button to download whole series/Season
- Refactored DownloadItem.tsx to be compatible with multiple items
- Updated queueActions.enqueue signature to be compatible with array of jobs
- Added download button beside season dropdown to download entire season
- Added download button to series page to download entire series
2024-12-01 14:23:38 -05:00
herrrta
b5c6403e2d # Add download size to offline media downloads
- Added getDownloadSize helper function to display media size
 in MB or GB when appropriate
2024-12-01 14:23:29 -05:00
herrrta
7eb7d17fa9 # New downloads page for downloaded TV-Series
- Renamed downloads.tsx to index.tsx
- Added new downloads/series.tsx page
- Downloading now saves series primary image
- Downloads index page now shows series primary image with downloaded episode counter
- Updated EpisodeCard.tsx to display more information
- Moved season dropdown from SeasonPicker.tsx into its own component SeasonDropdown.tsx
- Updated navigation in DownloadItem.tsx to direct to series page when a downloaded episode is clicked
2024-12-01 14:23:12 -05:00
Mateusz Kukieła
91b4e403e6 feat: add MacOS fullscreen support 2024-10-29 11:26:15 +01:00
60 changed files with 3246 additions and 2414 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: '❌ bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

59
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
labels:
- ['❌ bug']
projects:
- ['fredrikburmester/5']
assignees:
- fredrikburmester
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: device
attributes:
label: Which device and operating system are you using?
description: e.g. iPhone 15, iOS 18.1.1
validations:
required: true
- type: dropdown
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.22.0
- 0.21.0
- older
validations:
required: true
- type: textarea
id: screenshots
attributes:
label:
If applicable, please add screenshots to help explain your problem.
You can drag and drop images here or paste them directly into the comment box.

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.21.0",
"version": "0.22.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -23,7 +23,10 @@
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
},
"UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true
},
"config": {
"usesNonExemptEncryption": false
@@ -33,14 +36,15 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 46,
"versionCode": 47,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
]
},
"plugins": [
@@ -70,7 +74,8 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6"
"deploymentTarget": "15.6",
"useFrameworks": "static"
},
"android": {
"android": {

View File

@@ -0,0 +1,20 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
export default function CustomMenuLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Custom Links",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,73 @@
import {FlatList, TouchableOpacity, View} from "react-native";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import React, {useCallback, useEffect, useState} from "react";
import {useAtom} from "jotai/index";
import {apiAtom} from "@/providers/JellyfinProvider";
import {ListItem} from "@/components/ListItem";
import * as WebBrowser from 'expo-web-browser';
import Ionicons from '@expo/vector-icons/Ionicons';
import {Text} from "@/components/common/Text";
export interface MenuLink {
name: string,
url: string,
icon: string
}
export default function menuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets()
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
console.error("Menu links not found");
return;
}
setMenuLinks(config?.menuLinks as MenuLink[])
} catch (error) {
console.error("Failed to retrieve config:", error);
}
},
[api]
)
useEffect(() => { getMenuLinks() }, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={menuLinks}
renderItem={({item}) => (
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white"/>}
/>
</TouchableOpacity>
)
}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}/>
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No links</Text>
</View>
}
/>
);
}

View File

@@ -1,14 +1,11 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useDownload } from "@/providers/DownloadProvider";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
export default function IndexLayout() {
const router = useRouter();
return (
<Stack>
<Stack.Screen
@@ -35,11 +32,17 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="downloads"
name="downloads/index"
options={{
title: "Downloads",
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
options={{
title: "TV-Series",
}}
/>
<Stack.Screen
name="settings"
options={{

View File

@@ -1,154 +0,0 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
return (
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries?.map((items, index) => (
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
"New app version requires re-download",
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[
{
text: "Back",
onPress: () => router.back(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -0,0 +1,132 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() {
const navigation = useNavigation();
const local = useLocalSearchParams();
const { seriesId, episodeSeasonIndex } = local as {
seriesId: string;
episodeSeasonIndex: number | string | undefined;
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
);
const { downloadedFiles, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
) || []
);
} catch {
return [];
}
}, [downloadedFiles]);
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]);
const initialSeasonIndex = useMemo(
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
);
useEffect(() => {
if (series.length > 0) {
navigation.setOptions({
title: series[0].item.SeriesName,
});
} else {
storage.delete(seriesId);
router.back();
}
}, [series]);
const deleteSeries = useCallback(() => {
Alert.alert(
"Delete season",
"Are you sure you want to delete the entire season?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
);
}, [groupBySeason]);
return (
<View className="flex-1">
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,231 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import {useNavigation, useRouter} from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect, useMemo, useRef} from "react";
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {toast} from "sonner-native";
import {writeToLog} from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const movies = useMemo(() => {
try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
series[e.item.SeriesName!].push(e);
});
return Object.values(series);
} catch {
migration_20241124();
return [];
}
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
>
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
</TouchableOpacity>
)
})
}, [downloadedFiles]);
const deleteMovies = () => deleteFileByType("Movie")
.then(() => toast.success("Deleted all movies successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all movies");
});
const deleteShows = () => deleteFileByType("Episode")
.then(() => toast.success("Deleted all TV-Series successfully!"))
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error("Failed to delete all TV-Series");
});
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
return (
<>
<ScrollView
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name="close" size={24} color="red"/>
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
)}
<ActiveDownloads/>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item}/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">TV-Series</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => (
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
"New app version requires re-download",
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[
{
text: "Back",
onPress: () => router.back(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
);
}

View File

@@ -188,7 +188,7 @@ export default function index() {
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],

View File

@@ -2,32 +2,41 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useDownload } from "@/providers/DownloadProvider";
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import {clearLogs, useLog} from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Alert, ScrollView, View } from "react-native";
import {Alert, ScrollView, View} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useDownload();
const { deleteAllFiles, appSizeUsage } = useDownload();
const { logs } = useLog();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage
const remaining = await FileSystem.getFreeDiskStorageAsync()
const total = await FileSystem.getTotalDiskCapacityAsync()
return {app, remaining, total, used: (total - remaining) / total}
}
})
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
@@ -57,6 +66,27 @@ export default function settings() {
);
};
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
};
return (
<ScrollView
contentContainerStyle={{
@@ -81,6 +111,9 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
<Button className="my-2.5" color="black" onPress={logout}>
Log out
</Button>
</View>
<View>
@@ -92,42 +125,36 @@ export default function settings() {
<SettingToggles />
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<View className="mb-4 space-y-2">
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
<Progress.Bar
className="bg-gray-100/10"
indeterminate={appSizeLoading}
color="#9333ea"
width={null}
height={10}
borderRadius={6}
borderWidth={0}
progress={size?.used}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
)}
</View>
<Button
color="red"
onPress={onDeleteClicked}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
Delete all logs
</Button>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>

View File

@@ -8,13 +8,17 @@ import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import {useLocalSearchParams, useNavigation} from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { useEffect, useMemo } from "react";
import React, {useEffect} from "react";
import { useMemo } from "react";
import { View } from "react-native";
import {DownloadItems} from "@/components/DownloadItem";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
const page: React.FC = () => {
const navigation = useNavigation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -56,7 +60,43 @@ const page: React.FC = () => {
[item]
);
if (!item || !backdropUrl) return null;
const {data: allEpisodes, isLoading} = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
},
enabled: !!api && !!user?.Id && !!item?.Id
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
)}
/>
</View>
)
)
})
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
return (
<ParallaxScrollView

View File

@@ -6,7 +6,7 @@ import { withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "react-native-bottom-tabs/react-navigation";
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
@@ -18,6 +18,7 @@ import type {
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -27,6 +28,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator);
export default function TabLayout() {
const [settings] = useSettings();
return (
<>
<SystemBars hidden={false} style="light" />
@@ -71,6 +73,18 @@ export default function TabLayout() {
: () => ({ sfSymbol: "rectangle.stack" }),
}}
/>
<NativeTabs.Screen
name="(custom-links)"
options={{
title: "Custom Links",
// @ts-expect-error
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
}}
/>
</NativeTabs>
</>
);

View File

@@ -30,9 +30,23 @@ import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, BackHandler, View } from "react-native";
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import {
Alert,
BackHandler,
View,
AppState,
AppStateStatus,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -73,7 +87,7 @@ export default function page() {
bitrateValue: string;
offline: string;
}>();
const [settings] = useSettings();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
@@ -166,69 +180,58 @@ export default function page() {
staleTime: 0,
});
const togglePlay = useCallback(
async (ms: number) => {
if (!api) return;
const togglePlay = useCallback(async () => {
if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
await videoRef.current?.pause();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
await videoRef.current?.pause();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(ms),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(ms),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
},
[
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
]
);
const play = useCallback(() => {
videoRef.current?.play();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
@@ -284,6 +287,8 @@ export default function page() {
if (!item?.Id || !stream) return;
console.log("onProgress ~", currentTimeInTicks, isPlaying);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -303,8 +308,7 @@ export default function page() {
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
togglePlay: togglePlay,
stopPlayback: stop,
offline,
});
@@ -338,29 +342,49 @@ export default function page() {
: 0;
}, [item]);
const backAction = () => {
videoRef.current?.stop();
return false;
};
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
return async () => {
videoRef.current?.stop();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
stop();
console.log("Unmounted");
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
console.log("App has come to the foreground!");
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
console.log("App has gone to the background!");
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
let initOptions = ["--sub-text-scale=60"];
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
@@ -455,21 +479,6 @@ export default function page() {
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</View>
{videoRef.current && (
<Controls

View File

@@ -169,51 +169,44 @@ const Player = () => {
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
}
},
[
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]
);
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: true,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
}
}, [
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]);
const play = useCallback(() => {
videoRef.current?.resume();
@@ -307,9 +300,9 @@ const Player = () => {
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
@@ -369,23 +362,10 @@ const Player = () => {
}));
};
const backAction = () => {
videoRef.current?.pause();
return false;
};
useFocusEffect(
React.useCallback(() => {
const onBackPress = () => {
return backAction();
};
BackHandler.addEventListener("hardwareBackPress", onBackPress);
play();
return async () => {
videoRef.current?.pause();
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
stop();
};
}, [])
);
@@ -467,20 +447,6 @@ const Player = () => {
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
<View
style={{
position: "absolute",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
opacity: isBuffering ? 1 : 0,
width: "100%",
height: "100%",
}}
pointerEvents="none"
>
<Loader />
</View>
</>
) : (
<Text>No video source...</Text>

View File

@@ -6,10 +6,11 @@ import {
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
@@ -310,50 +311,54 @@ function Layout() {
<JobQueueProvider>
<JellyfinProvider>
<PlaySettingsProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>

BIN
assets/icons/list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,27 +1,30 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom, useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
/**
* Get horrizontal poster for movie and episode, with failover to primary.
* Get horizontal poster for movie and episode, with failover to primary.
*/
const url = useMemo(() => {
if (!api) return;
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"}
`}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
<View className="w-full h-full flex items-center justify-center">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
{showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center">
<Ionicons name="play-circle" size={40} color="white" />
</View>
)}
</View>
{!progress && <WatchedIndicator item={item} />}
{progress > 0 && (
<>

View File

@@ -6,7 +6,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import native from "@/utils/profiles/native";
import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
@@ -19,9 +18,9 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router, useFocusEffect } from "expo-router";
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -34,15 +33,22 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
}
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
@@ -56,23 +62,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined,
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,
[user]
);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized",
[settings]
);
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);
/**
* Bottom sheet
@@ -89,69 +86,169 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.dismiss();
}, []);
/**
* Start download
*/
const initiateDownload = useCallback(async () => {
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
// region computed
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
const pendingItems = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: selectedAudioStream,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: selectedMediaSource.Id,
subtitleStreamIndex: selectedSubtitleStream,
deviceProfile: download,
});
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
);
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
return;
}
const progress = useMemo(() => {
if (itemIds.length == 1)
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
itemIds.length) *
100
);
}, [queue, itemsProcesses, itemIds]);
const { mediaSource, url } = res;
const itemsQueued = useMemo(() => {
return (
pendingItems.length > 0 &&
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, pendingItems]);
// endregion computed
if (!url || !mediaSource) throw new Error("No url");
// region helper functions
const navigateToDownloads = () => router.push("/downloads");
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
const onDownloadedPress = () => {
const firstItem = items?.[0];
router.push(
firstItem.Type !== "Episode"
? "/downloads"
: ({
pathname: `/downloads/${firstItem.SeriesId}`,
params: {
episodeSeasonIndex: firstItem.ParentIndexNumber,
},
} as Href)
);
};
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, mediaSource);
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...pendingItems);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
}))
);
}
} else {
return await startRemuxing(item, url, mediaSource);
toast.error("You are not allowed to download files.");
}
}, [
api,
item,
startBackgroundDownload,
user?.Id,
queue,
setQueue,
pendingItems,
usingOptimizedServer,
userCanDownload,
// Need to be reference at the time async lambda is created for initiateDownload
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
maxBitrate,
settings?.downloadMethod,
]);
/**
* Check if item is downloaded
* Start download
*/
const { downloadedFiles } = useDownload();
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let mediaSource = selectedMediaSource;
let audioIndex: number | undefined = selectedAudioStream;
let subtitleIndex: number | undefined = selectedSubtitleStream;
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
for (const item of items) {
if (pendingItems.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
));
}
return downloadedFiles.some((file) => file.item.Id === item.Id);
}, [downloadedFiles, item.Id]);
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: maxBitrate.value,
mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
});
if (!res) {
Alert.alert(
"Something went wrong",
"Could not get stream url from Jellyfin"
);
continue;
}
const { mediaSource: source, url } = res;
if (!url || !source) throw new Error("No url");
saveDownloadItemInfoToDiskTmp(item, source, url);
if (usingOptimizedServer) {
await startBackgroundDownload(url, item, source);
} else {
await startRemuxing(item, url);
}
}
},
[
api,
user?.Id,
pendingItems,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings,
maxBitrate,
usingOptimizedServer,
startBackgroundDownload,
startRemuxing,
]
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -163,31 +260,38 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
),
[]
);
// endregion helper functions
const process = useMemo(() => {
if (!processes) return null;
// Allow to select & set settings for single download
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
return processes.find((process) => process?.item?.Id === item.Id);
}, [processes, item.Id]);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, pendingItems, settings])
);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
{...props}
>
{process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
{process.progress === 0 ? (
{processes && itemsProcesses.length > 0 ? (
<TouchableOpacity onPress={navigateToDownloads}>
{progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
@@ -195,25 +299,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
)}
</TouchableOpacity>
) : queue.some((i) => i.id === item.Id) ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
) : itemsQueued ? (
<TouchableOpacity onPress={navigateToDownloads}>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<Ionicons name="cloud-download" size={26} color="#9333ea" />
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<BottomSheetModal
@@ -236,62 +332,46 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={(val) => setMaxBitrate(val)}
onChange={setMaxBitrate}
selected={maxBitrate}
/>
<MediaSourceSelector
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
{pendingItems.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</>
)}
</View>
<Button
className="mt-auto"
onPress={() => {
if (userCanDownload === true) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
toast.error("You are not allowed to download files.");
}
}}
onPress={acceptDownloadOptions}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
{settings?.downloadMethod === "optimized" ? (
<Text className="text-xs">Using optimized server</Text>
) : (
<Text className="text-xs">Using default method</Text>
)}
<Text className="text-xs">
{usingOptimizedServer
? "Using optimized server"
: "Using default method"}
</Text>
</View>
</View>
</BottomSheetView>
@@ -299,3 +379,19 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
</View>
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
item,
}) => {
return (
<DownloadItems
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" />
)}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadItem } from "@/components/DownloadItem";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlayButton } from "@/components/PlayButton";
@@ -32,6 +32,16 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import {
brightness,
ColorMatrix,
colorTone,
concatColorMatrices,
contrast,
saturate,
sepia,
tint,
} from "react-native-color-matrix-image-filters";
export type SelectedOptions = {
bitrate: Bitrate;
@@ -49,7 +59,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
const insets = useSafeAreaInsets();
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [loadingLogo, setLoadingLogo] = useState(false);
const [headerHeight, setHeaderHeight] = useState(350);
const [selectedOptions, setSelectedOptions] = useState<
@@ -87,7 +97,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadItem item={item} />
<DownloadSingleItem item={item} />
<PlayedStatus item={item} />
</View>
)}
@@ -139,18 +149,45 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
<ColorMatrix
matrix={[
1,
0,
0,
0,
0, // Red channel remains unchanged
0,
1,
0,
0,
0, // Green channel remains unchanged
0,
0,
1,
0,
0, // Blue channel remains unchanged
1,
1,
1,
1,
-1, // Make black (R=0, G=0, B=0) transparent
]}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
>
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
</ColorMatrix>
) : null}
</>
}
@@ -228,6 +265,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<OverviewText text={item.Overview} className="px-4 my-4" />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && (
@@ -243,10 +284,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View>
)}
{item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item.Id} />
</>
)}

View File

@@ -31,6 +31,7 @@ import Animated, {
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -111,18 +112,11 @@ export const PlayButton: React.FC<Props> = ({
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
// If we're opening a currently playing item, don't restart the media.
// Instead just open controls.
if (isOpeningCurrentlyPlayingMedia) {
CastContext.showExpandedControls();
return;
}
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: ios,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,

View File

@@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.id} process={p} />
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>
@@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
FFmpegKit.cancel();
FFmpegKit.cancel(Number(id));
setProcesses((prev) => prev.filter((p) => p.id !== id));
}
},

View File

@@ -0,0 +1,47 @@
import { Text } from "@/components/common/Text";
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps {
items: BaseItemDto[];
}
export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { downloadedFiles, getDownloadedItemSize } = useDownload();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedFiles) return;
let s = 0;
for (const item of items) {
if (!item.Id) continue;
const size = getDownloadedItemSize(item.Id);
if (size) {
s += size;
}
}
setSize(bytesToReadable(s));
}, [itemIds]);
const sizeText = useMemo(() => {
if (!size) return "...";
return size;
}, [size]);
return (
<>
<Text className="text-xs text-neutral-500" {...props}>
{sizeText}
</Text>
</>
);
};

View File

@@ -1,37 +1,35 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/common/Text";
import { runtimeTicksToSeconds } from "@/utils/time";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
interface EpisodeCardProps {
interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with action sheet options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, []);
}, [item]);
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -76,32 +74,29 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col w-44 mr-2"
key={item.Id}
className="flex flex-col mb-4"
>
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
<View className="shrink">
<Text numberOfLines={2} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
)}
<ItemCardText item={item} />
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.Overview}
</Text>
</TouchableOpacity>
);
};

View File

@@ -7,6 +7,7 @@ import * as Haptics from "expo-haptics";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
@@ -97,6 +98,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
</View>
)}
<ItemCardText item={item} />
<DownloadSize items={[item]} />
</TouchableOpacity>
);
};

View File

@@ -1,55 +1,82 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { ScrollView, View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import {TouchableOpacity, View} from "react-native";
import { Text } from "../common/Text";
import { useMemo } from "react";
import { SeasonPicker } from "../series/SeasonPicker";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const groupBySeason = useMemo(() => {
const seasons: Record<string, BaseItemDto[]> = {};
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet();
items.forEach((item) => {
if (!seasons[item.SeasonName!]) {
seasons[item.SeasonName!] = [];
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
showActionSheetWithOptions({
options,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex == destructiveButtonIndex) {
deleteSeries();
}
}
seasons[item.SeasonName!].push(item);
});
return Object.values(seasons).sort(
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
);
}, [items]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
};
}, [showActionSheetWithOptions, deleteSeries]);
return (
<View>
<View className="flex flex-row items-center justify-between px-4">
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text>
<TouchableOpacity
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
onLongPress={showActionSheet}
>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold px-4">
{seasonItems[0].SeasonName}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{seasonItems.sort(sortByIndex)?.map((item, index) => (
<EpisodeCard item={item} key={index} />
))}
</View>
</ScrollView>
</View>
))}
</View>
<View className="w-28 mt-2 flex flex-col">
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
<DownloadSize items={items} />
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,44 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
interface StepperProps {
value: number,
step: number,
min: number,
max: number,
onUpdate: (value: number) => void,
appendValue?: string,
}
export const Stepper: React.FC<StepperProps> = ({
value,
step,
min,
max,
onUpdate,
appendValue
}) => {
return (
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text
className={
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
}
>
{value}{appendValue}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() => onUpdate(Math.min(max, value + step))}
>
<Text>+</Text>
</TouchableOpacity>
</View>
)
}

View File

@@ -0,0 +1,121 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
seasons: BaseItemDto[];
initialSeasonIndex?: number;
state: SeasonIndexState;
onSelect: (season: BaseItemDto) => void;
};
type SeasonKeys = {
id: keyof BaseItemDto;
title: keyof BaseItemDto;
index: keyof BaseItemDto;
};
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({
item,
seasons,
initialSeasonIndex,
state,
onSelect,
}) => {
const keys = useMemo<SeasonKeys>(
() =>
item.Type === "Episode"
? {
id: "ParentId",
title: "SeasonName",
index: "ParentIndexNumber",
}
: {
id: "Id",
title: "Name",
index: "IndexNumber",
},
[item]
);
const seasonIndex = useMemo(
() => state[(item[keys.id] as string) ?? ""],
[state]
);
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season[keys.index] === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season[keys.index] === 1);
const season0 = seasons.find((season: any) => season[keys.index] === 0);
const firstSeason = season1 || season0 || seasons[0];
onSelect(firstSeason);
}
if (initialIndex !== undefined) {
const initialSeason = seasons.find(
(season: any) => season[keys.index] === initialIndex
);
if (initialSeason) onSelect(initialSeason!);
else throw Error("Initial index could not be found!");
}
}
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => (
<DropdownMenu.Item
key={season[keys.title]}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>
{season[keys.title]}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -2,30 +2,27 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { View } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
type Props = {
item: BaseItemDto;
initialSeasonIndex?: number;
};
type SeasonIndexState = {
[seriesId: string]: number;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
@@ -35,8 +32,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const seasonIndex = seasonIndexState[item.Id ?? ""];
const router = useRouter();
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
queryFn: async () => {
@@ -61,37 +56,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
let initialIndex: number | undefined;
if (initialSeasonIndex !== undefined) {
// Use the provided initialSeasonIndex if it exists in the seasons
const seasonExists = seasons.some(
(season: any) => season.IndexNumber === initialSeasonIndex
);
if (seasonExists) {
initialIndex = initialSeasonIndex;
}
}
if (initialIndex === undefined) {
// Fall back to the previous logic if initialIndex is not set
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
const firstSeason = season1 || season0 || seasons[0];
initialIndex = firstSeason.IndexNumber;
}
if (initialIndex !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: initialIndex,
}));
}
}
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
@@ -148,39 +112,37 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
minHeight: 144 * nrOfEpisodes,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.map((season: any) => (
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
<View className="flex flex-row justify-start items-center px-4">
<SeasonDropdown
item={item}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}}
/>
<DownloadItems
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons
name="download-multiple"
size={20}
color="white"
/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons
name="check-all"
size={20}
color="#9333ea"
/>
)}
/>
</View>
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
@@ -218,7 +180,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</Text>
</View>
<View className="self-start ml-auto -mt-0.5">
<DownloadItem item={e} />
<DownloadSingleItem item={e} />
</View>
</View>

View File

@@ -3,6 +3,7 @@ import { TouchableOpacity, View, ViewProps } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { LANGUAGES } from "@/constants/Languages";
import { TextInput } from "react-native-gesture-handler";
interface Props extends ViewProps {}
@@ -122,6 +123,45 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Subtitle Size</Text>
<Text className="text-xs opacity-50">
Choose a default subtitle size for direct play (only works for
some subtitle formats).
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
subtitleSize: Math.max(0, settings.subtitleSize - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

@@ -4,7 +4,7 @@ import {
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
@@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import {
Linking,
Switch,
@@ -32,6 +32,7 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
@@ -403,6 +404,26 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View>
)}
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Show Custom Menu Links</Text>
<Text className="text-xs opacity-50">
Show custom menu links defined inside your Jellyfin web config.json file
</Text>
<TouchableOpacity
onPress={() =>
Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")
}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
/>
</View>
</View>
</View>
@@ -463,7 +484,44 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View
pointerEvents={
settings.downloadMethod === "remux" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "remux"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Remux max download</Text>
<Text className="text-xs opacity-50 shrink">
This is the total media you want to be able to download at the same time.
</Text>
</View>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) => updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Auto download</Text>
<Text className="text-xs opacity-50 shrink">

View File

@@ -0,0 +1,68 @@
import React, { useEffect } from "react";
import { View, StyleSheet } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { Slider } from "react-native-awesome-slider";
import * as Brightness from "expo-brightness";
import { Ionicons } from "@expo/vector-icons";
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
const BrightnessSlider = () => {
const brightness = useSharedValue(50);
const min = useSharedValue(0);
const max = useSharedValue(100);
useEffect(() => {
const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100;
};
fetchInitialBrightness();
}, []);
const handleValueChange = async (value: number) => {
brightness.value = value;
await Brightness.setBrightnessAsync(value / 100);
};
return (
<View style={styles.sliderContainer}>
<Slider
progress={brightness}
minimumValue={min}
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons
name="sunny"
size={20}
color="#FDFDFD"
style={{
marginLeft: 8,
}}
/>
</View>
);
};
const styles = StyleSheet.create({
sliderContainer: {
width: 150,
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
});
export default BrightnessSlider;

View File

@@ -50,6 +50,14 @@ import { VideoProvider } from "./contexts/VideoContext";
import * as Haptics from "expo-haptics";
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import BrightnessSlider from "./BrightnessSlider";
import SkipButton from "./SkipButton";
import { debounce } from "lodash";
import { EpisodeList } from "./EpisodeList";
import { BlurView } from "expo-blur";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
interface Props {
item: BaseItemDto;
@@ -63,7 +71,7 @@ interface Props {
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
@@ -110,12 +118,15 @@ export const Controls: React.FC<Props> = ({
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
!offline && enableTrickplay
);
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(item, !offline && enableTrickplay);
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
@@ -238,12 +249,28 @@ export const Controls: React.FC<Props> = ({
}
}, [item, isVlc]);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const toggleControls = () => setShowControls(!showControls);
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
await seek(
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
@@ -254,27 +281,20 @@ export const Controls: React.FC<Props> = ({
);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
const progressInTicks = msToTicks(value);
console.log("Progress in ticks", progressInTicks);
calculateTrickplayUrl(progressInTicks);
const handleSliderChange = (value: number) => {
const progressInTicks = isVlc ? msToTicks(value) : value;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 10),
[]
);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
@@ -318,282 +338,408 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return (
<View
style={{
position: "absolute",
left: -57,
bottom: 15,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
const [EpisodeView, setEpisodeView] = useState(false);
const switchOnEpisodeMode = () => {
setEpisodeView(true);
if (isPlaying) togglePlay(progress.value);
};
const gotoEpisode = async (itemId: string) => {
const item = await getItemById(api, itemId);
console.log("Item", item);
if (!settings || !item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
};
return (
<ControlProvider
item={item}
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
<SafeAreaView
style={{
flex: 1,
position: "absolute",
top: insets.top,
left: insets.left,
right: insets.right,
bottom: insets.bottom,
}}
>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<View
style={[
{
position: "absolute",
bottom: 97,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
{EpisodeView ? (
<EpisodeList item={item} close={() => setEpisodeView(false)} />
) : (
<>
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
setAudioTrack={setAudioTrack}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</VideoProvider>
<View
style={{
position: "absolute",
bottom: 94,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPressIn={() => {
toggleControls();
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<View
style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{Platform.OS !== "ios" && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
if (stop) await stop();
router.back();
<Pressable
onPressIn={() => {
toggleControls();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
style={{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4`}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
}}
></Pressable>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`}
style={[
{
position: "absolute",
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
<View className="flex flex-row items-center space-x-4">
{item?.Type === "Episode" && (
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
onPress={() => {
switchOnEpisodeMode();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
)}
{nextItem && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={async () => {
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
top: "50%", // Center vertically
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
transform: [{ translateY: -22.5 }], // Adjust for the button's height (half of 45)
paddingHorizontal: "28%", // Add some padding to the left and right
opacity: showControls ? 1 : 0,
}}
pointerEvents={showControls ? "box-none" : "none"}
>
<View
style={{
position: "absolute",
alignItems: "center",
transform: [{ rotate: "270deg" }], // Rotate the slider to make it vertical
bottom: 30,
}}
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name="refresh-outline"
size={26}
size={50}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
size={50}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<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: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name="refresh-outline" size={50} color="white" />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "box-none" : "none"}
className={`flex flex-col p-4`}
>
<View
className="shrink flex flex-col justify-center h-full mb-2"
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end", // Shrink height based on content
}}
>
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{item?.ProductionYear}
</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end",
marginRight: insets.right,
}}
>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
</View>
</View>
<View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
>
<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",
}}
renderThumb={() => (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
width: 18,
height: 18,
left: -2,
borderRadius: 10,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
<Text
style={{
position: "absolute",
bottom: 5,
left: 5,
color: "white",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 5,
borderRadius: 5,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${
time.seconds < 10 ? `0${time.seconds}` : time.seconds
}`}
</Text>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<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>
/>
)}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<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>
</View>
</View>
</View>
</View>
</SafeAreaView>
</>
)}
</ControlProvider>
);
};

View File

@@ -0,0 +1,289 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState, useRef } from "react";
import { View, TouchableOpacity } from "react-native";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Loader } from "@/components/Loader";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
import { router, useLocalSearchParams } from "expo-router";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { useSettings } from "@/utils/atoms/settings";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { Item } from "zeego/dropdown-menu";
type Props = {
item: BaseItemDto;
close: () => void;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const EpisodeList: React.FC<Props> = ({ item, close }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); // Get safe area insets
const [settings] = useSettings();
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
const scrollToIndex = (index: number) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
// Set the initial season index
useEffect(() => {
if (item.SeriesId) {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
}));
}
}, []);
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
// This effect fetches the series item data/
useEffect(() => {
if (item.SeriesId) {
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
(res) => {
setSeriesItem(res);
}
);
}
}, [item.SeriesId]);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.SeriesId],
queryFn: async () => {
if (!api || !user?.Id || !item.SeriesId) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
{
params: {
userId: user?.Id,
itemId: item.SeriesId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
return response.data.Items;
},
enabled: !!api && !!user?.Id && !!item.SeasonId,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId || "",
userId: user.Id,
seasonId: selectedSeasonId || undefined,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!selectedSeasonId,
});
useEffect(() => {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
if (index !== undefined && index !== -1) {
setTimeout(() => {
scrollToIndex(index);
}, 400);
}
}
}, [episodes, item]);
const queryClient = useQueryClient();
useEffect(() => {
for (let e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
if (!e.Id) return;
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: e.Id,
});
return res;
},
staleTime: 60 * 5 * 1000,
});
}
}, [episodes]);
// Scroll to the current item when episodes are fetched
useEffect(() => {
if (episodes && scrollViewRef.current) {
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
if (currentItemIndex !== -1) {
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
}
}
}, [episodes, item.Id]);
const { audioIndex, subtitleIndex, bitrateValue } = useLocalSearchParams<{
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const gotoEpisode = async (itemId: string) => {
const item = await getItemById(api, itemId);
if (!settings || !item) return;
const { mediaSource } = getDefaultPlaySettings(item, settings);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
if (!bitrateValue) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
};
if (!episodes) {
return <Loader />;
}
return (
<View
style={{
position: "absolute",
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<>
<View
style={{
justifyContent: "space-between",
}}
className={`flex flex-row items-center space-x-2 z-10 p-4`}
>
{seriesItem && (
<SeasonDropdown
item={seriesItem}
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[item.SeriesId ?? ""]: season.IndexNumber,
}));
}}
/>
)}
<TouchableOpacity
onPress={async () => {
close();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<HorizontalScroll
ref={scrollViewRef}
data={episodes}
extraData={item}
renderItem={(_item, idx) => (
<View
key={_item.Id}
style={{}}
className={`flex flex-col w-44 ${
item.Id !== _item.Id ? "opacity-75" : ""
}`}
>
<TouchableOpacity
onPress={() => {
gotoEpisode(_item.Id);
}}
>
<ContinueWatchingPoster
item={_item}
useEpisodePoster
showPlayButton={_item.Id !== item.Id}
/>
</TouchableOpacity>
<View className="shrink">
<Text
numberOfLines={2}
style={{
lineHeight: 18, // Adjust this value based on your text size
height: 36, // lineHeight * 2 for consistent two-line space
}}
>
{_item.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-475">
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(_item.RunTimeTicks)}
</Text>
</View>
<View className="self-start mt-2">
<DownloadSingleItem item={_item} />
</View>
<Text
numberOfLines={5}
className="text-xs text-neutral-500 shrink"
>
{_item.Overview}
</Text>
</View>
)}
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
/>
</>
</View>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
interface SkipButtonProps {
onPress: () => void;
showButton: boolean;
buttonText: string;
}
const SkipButton: React.FC<SkipButtonProps> = ({
onPress,
showButton,
buttonText,
}) => {
return (
<View style={{ display: showButton ? "flex" : "none" }}>
<TouchableOpacity onPress={onPress} style={styles.button}>
<Text style={styles.text}>{buttonText}</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "rgba(0, 0, 0, 0.75)",
borderRadius: 5,
paddingHorizontal: 10,
paddingVertical: 15,
borderWidth: 2,
borderColor: "#5A5454",
},
text: {
color: "white",
fontWeight: "bold",
},
});
export default SkipButton;

View File

@@ -22,13 +22,13 @@
}
},
"production": {
"channel": "0.21.0",
"channel": "0.22.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.21.0",
"channel": "0.22.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -1,8 +1,8 @@
import index from "@/app/(auth)/(tabs)/(home)";
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps {
@@ -12,81 +12,53 @@ interface AdjacentEpisodesProps {
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom);
const { data: previousItem } = useQuery({
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
queryFn: async (): Promise<BaseItemDto | null> => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
if (
!api ||
!parentId ||
indexNumber === undefined ||
indexNumber === null ||
indexNumber - 1 < 1
) {
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!api || !item || !item.SeriesId) {
return null;
}
const newIndexNumber = indexNumber - 2;
const res = await getItemsApi(api).getItems({
parentId: parentId!,
startIndex: newIndexNumber,
limit: 1,
sortBy: ["IndexNumber"],
includeItemTypes: ["Episode", "Audio"],
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
adjacentTo: item.Id,
limit: 3,
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
throw new Error("Previous item is not correct");
}
return res.data.Items?.[0] || null;
return res.data.Items || null;
},
enabled: item?.Type === "Episode" || item?.Type === "Audio",
enabled:
!!api &&
!!item?.Id &&
!!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"),
staleTime: 0,
});
const { data: nextItem } = useQuery({
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
queryFn: async (): Promise<BaseItemDto | null> => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (
!api ||
!parentId ||
indexNumber === undefined ||
indexNumber === null
) {
console.log("No next item", {
itemId: item?.Id,
parentId: parentId,
indexNumber: indexNumber,
});
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
const res = await getItemsApi(api).getItems({
parentId: parentId!,
startIndex: indexNumber,
sortBy: ["IndexNumber"],
limit: 1,
includeItemTypes: ["Episode", "Audio"],
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
return adjacentItems[0];
}, [adjacentItems, item]);
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
throw new Error("Previous item is not correct");
}
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
return res.data.Items?.[0] || null;
},
enabled: item?.Type === "Episode" || item?.Type === "Audio",
staleTime: 0,
});
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
return { previousItem, nextItem };
};

View File

@@ -17,16 +17,20 @@ const useDefaultPlaySettings = (
// 2. Get default or preferred audio
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
)?.Index;
const firstAudioIndex = mediaSource?.MediaStreams?.find(
(x) => x.Type === "Audio"
)?.Index;
// 3. Get default or preferred subtitle
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
(x) =>
x.Type === "Subtitle" &&
x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
(stream) => stream.Type === "Subtitle" && stream.IsDefault
)?.Index;
@@ -38,7 +42,9 @@ const useDefaultPlaySettings = (
defaultAudioIndex:
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
defaultSubtitleIndex:
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
preferedSubtitleIndex !== undefined
? preferedSubtitleIndex
: defaultSubtitleIndex || undefined,
defaultMediaSource: mediaSource || undefined,
defaultBitrate: bitrate || undefined,
};

View File

@@ -1,7 +1,7 @@
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage";
import { writeToLog } from "@/utils/log";
import { writeErrorLog, writeInfoLog, writeToLog } from "@/utils/log";
import {
BaseItemDto,
MediaSourceInfo,
@@ -9,11 +9,34 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner-native";
import useImageStorage from "./useImageStorage";
import useDownloadHelper from "@/utils/download";
import { Api } from "@jellyfin/sdk";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
const createFFmpegCommand = (url: string, output: string) => [
"-y", // overwrite output files without asking
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
// region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html
"-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist
"-multiple_requests 1", // http
"-tcp_nodelay 1", // http
// endregion ffmpeg protocol commands
"-fflags +genpts", // format flags
`-i ${url}`, // infile
"-map 0:v -map 0:a", // select all streams for video & audio
"-c copy", // streamcopy, preventing transcoding
"-bufsize 25M", // amount of data processed before calculating current bitrate
"-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output
output,
];
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -24,26 +47,125 @@ import useImageStorage from "./useImageStorage";
*/
export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const { saveImage } = useImageStorage();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveDownloadedItemInfo, setProcesses, processes } = useDownload();
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
};
const completeCallback = useCallback(
async (session: FFmpegSession, item: BaseItemDto) => {
try {
let endTime;
const returnCode = await session.getReturnCode();
const startTime = new Date();
if (returnCode.isValueSuccess()) {
endTime = new Date();
const stat = await session.getLastReceivedStatistics();
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
saveDownloadedItemInfo(item, stat.getSize());
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`.replace(/^ +/g, "")
);
toast.success("Download completed");
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s. All logs: ${allLogs}`.replace(/^ +/g, "")
);
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeInfoLog(
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name},
start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()},
duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`.replace(/^ +/g, "")
);
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
writeErrorLog(
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`.replace(/^ +/g, "")
);
}
},
[processes, setProcesses]
);
const statisticsCallback = useCallback(
(statistics: Statistics, item: BaseItemDto) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
id: statistics.getSessionId().toString(),
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
},
[setProcesses, completeCallback]
);
const startRemuxing = useCallback(
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
async (item: BaseItemDto, url: string) => {
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
// First lets save any important assets we want to present to the user offline
await onSaveAssets(api, item);
toast.success(`Download started for ${item.Name}`, {
action: {
@@ -55,128 +177,34 @@ export const useRemuxHlsToMp4 = () => {
},
});
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
try {
setProcesses((prev) => [
...prev,
{
id: "",
deviceId: "",
inputUrl: "",
item: item,
itemId: item.Id!,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
},
]);
const job: JobStatus = {
id: "",
deviceId: "",
inputUrl: url,
item: item,
itemId: item.Id!,
outputPath: output,
progress: 0,
status: "downloading",
timestamp: new Date(),
};
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
setProcesses((prev) => [...prev, job]);
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
const startTime = new Date();
let endTime;
if (returnCode.isValueSuccess()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
);
if (!item) throw new Error("Item is undefined");
await saveDownloadedItemInfo(item);
toast.success("Download completed");
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
resolve();
} else if (returnCode.isValueError()) {
endTime = new Date();
const allLogs = session.getAllLogsAsString();
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s. All logs: ${allLogs}`
);
reject(new Error("Remuxing failed"));
} else if (returnCode.isValueCancel()) {
endTime = new Date();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
item.Name
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
(endTime.getTime() - startTime.getTime()) / 1000
}s`
);
resolve();
}
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} catch (e) {
const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
);
reject(error);
}
});
});
await FFmpegKit.executeAsync(
createFFmpegCommand(url, output).join(" "),
(session) => completeCallback(session, item),
undefined,
(s) => statisticsCallback(s, item)
);
} catch (e) {
const error = e as Error;
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
writeErrorLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
Error: ${error.message}, Stack: ${error.stack}`
);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
@@ -184,7 +212,7 @@ export const useRemuxHlsToMp4 = () => {
throw error; // Re-throw the error to propagate it to the caller
}
},
[]
[settings, processes, setProcesses, completeCallback, statisticsCallback]
);
const cancelRemuxing = useCallback(() => {

View File

@@ -1,8 +1,7 @@
// hooks/useTrickplay.ts
import { apiAtom } from "@/providers/JellyfinProvider";
import { ticksToMs } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -111,9 +110,45 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
[trickplayInfo, item, api, enabled]
);
const prefetchAllTrickplayImages = useCallback(() => {
if (!api || !enabled || !trickplayInfo || !item.Id || !item.RunTimeTicks) {
return;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight, Width, Height } = data;
if (
!Interval ||
!TileWidth ||
!TileHeight ||
!resolution ||
!Width ||
!Height
) {
throw new Error("Invalid trickplay data");
}
// Calculate tiles per sheet
const tilesPerRow = TileWidth;
const tilesPerColumn = TileHeight;
const tilesPerSheet = tilesPerRow * tilesPerColumn;
const totalTiles = Math.ceil(ticksToMs(item.RunTimeTicks) / Interval);
const totalIndexes = Math.ceil(totalTiles / tilesPerSheet);
// Prefetch all trickplay images
for (let index = 0; index < totalIndexes; index++) {
const url = `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`;
Image.prefetch(url);
}
}, [trickplayInfo, item, api, enabled]);
return {
trickPlayUrl: enabled ? trickPlayUrl : null,
calculateTrickplayUrl: enabled ? calculateTrickplayUrl : () => null,
prefetchAllTrickplayImages: enabled
? prefetchAllTrickplayImages
: () => null,
trickplayInfo: enabled ? trickplayInfo : null,
};
};

View File

@@ -1,91 +1,27 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { Alert } from "react-native";
import { Router, useRouter } from "expo-router";
import { Api } from "@jellyfin/sdk";
import { useAtomValue } from "jotai";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps {
isPlaying: boolean;
pauseVideo: () => void;
playVideo: () => void;
togglePlay: () => void;
stopPlayback: () => void;
offline?: boolean;
offline: boolean;
}
export const useWebSocket = ({
isPlaying,
pauseVideo,
playVideo,
togglePlay,
stopPlayback,
offline = false,
offline,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: deviceId } = useQuery({
queryKey: ["deviceId"],
queryFn: async () => {
return await getOrSetDeviceId();
},
staleTime: Infinity,
});
const { ws } = useWebSocketContext();
useEffect(() => {
if (offline || !deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user, offline]);
useEffect(() => {
if (offline || !ws) return;
if (!ws) return;
if (offline) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
@@ -95,8 +31,7 @@ export const useWebSocket = ({
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
togglePlay();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
@@ -108,7 +43,9 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
return { isConnected };
return () => {
ws.onmessage = null;
};
}, [ws, stopPlayback, togglePlay, isPlaying, router]);
};

View File

@@ -1,6 +1,8 @@
package expo.modules.vlcplayer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -32,6 +34,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var startPosition: Int? = 0
private var isMediaReady: Boolean = false
private var externalTrack: Map<String, String>? = null
var hasSource: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val updateInterval = 1000L // 1 second
private val updateProgressRunnable = object : Runnable {
override fun run() {
updateVideoProgress()
handler.postDelayed(this, updateInterval)
}
}
init {
setupView()
@@ -48,6 +60,11 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
fun setSource(source: Map<String, Any>) {
if (hasSource) {
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return
}
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
val autoplay = source["autoplay"] as? Boolean ?: false
val isNetwork = source["isNetwork"] as? Boolean ?: false
@@ -87,6 +104,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
// }
hasSource = true
if (autoplay) {
Log.d("VlcPlayerView", "Playing...")
@@ -97,15 +115,18 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
fun play() {
mediaPlayer?.play()
isPaused = false
handler.post(updateProgressRunnable) // Start updating progress
}
fun pause() {
mediaPlayer?.pause()
isPaused = true
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun stop() {
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
}
fun seekTo(time: Int) {
@@ -170,6 +191,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
println("onDetachedFromWindow")
super.onDetachedFromWindow()
mediaPlayer?.stop()
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
media?.release()
mediaPlayer?.release()
@@ -187,7 +209,9 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
MediaPlayer.Event.Buffering,
MediaPlayer.Event.EndReached,
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
MediaPlayer.Event.TimeChanged -> updateVideoProgress()
MediaPlayer.Event.TimeChanged -> {
// Do nothing here, as we are updating progress every 1 second
}
}
}
@@ -241,7 +265,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val currentTimeMs = player.time.toInt()
val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Set subtitle URL if available
if (player.isPlaying && !isMediaReady) {

View File

@@ -5,7 +5,7 @@ import UIKit
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 0.5
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
@@ -14,13 +14,17 @@ class VlcPlayerView: ExpoView {
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
// setupNotifications()
setupNotifications()
}
// MARK: - Setup
@@ -48,82 +52,71 @@ class VlcPlayerView: ExpoView {
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification, object: nil)
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
}
// MARK: - Public Methods
@objc func play() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
@objc func pause() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.pause()
self.isPaused = true
}
self.mediaPlayer?.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let player = self.mediaPlayer else { return }
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
let wasPlaying = player.isPlaying
if wasPlaying {
player.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
// Wait for a short moment to ensure the seek has been processed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if wasPlaying {
player.play()
}
self.updatePlayerState()
}
} else {
print("Error: Unable to retrieve video duration")
self.play()
}
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(startPosition)")
self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)")
let uri = source["uri"] as? String
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
guard let uri = uri, !uri.isEmpty else {
guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
@@ -134,14 +127,8 @@ class VlcPlayerView: ExpoView {
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
if uri.starts(with: "file://") {
if let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
print("Error: Invalid local file URL")
self.onVideoError?(["error": "Invalid local file URL"])
return
}
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
media = VLCMedia(path: uri)
}
@@ -150,18 +137,8 @@ class VlcPlayerView: ExpoView {
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
// Apply subtitle options
let subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?? -1
print("Debug: Subtitle track index from source: \(subtitleTrackIndex)")
if subtitleTrackIndex >= -1 {
self.setSubtitleTrack(subtitleTrackIndex)
print("Debug: Set subtitle track to index: \(subtitleTrackIndex)")
} else {
print("Debug: Subtitle track index is less than -1, not setting")
}
self.mediaPlayer?.media = media
self.hasSource = true
if autoplay {
print("Playing...")
@@ -170,33 +147,8 @@ class VlcPlayerView: ExpoView {
}
}
// TODO
// @objc func setMuted(_ muted: Bool) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.isMuted = muted
// }
// }
// TODO
// @objc func setVolume(_ volume: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.audio?.volume = Int32(volume)
// }
// }
// TODO
// @objc func setVideoAspectRatio(_ ratio: String) {
// DispatchQueue.main.async {
// ratio.withCString { cString in
// self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
// }
// }
// }
@objc func setAudioTrack(_ trackIndex: Int) {
DispatchQueue.main.async {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
@objc func getAudioTracks() -> [[String: Any]]? {
@@ -211,73 +163,27 @@ class VlcPlayerView: ExpoView {
}
}
// @objc func getAudioTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// guard let trackNames = mediaPlayer.audioTrackNames,
// let trackIndexes = mediaPlayer.audioTrackIndexes
// else {
// DispatchQueue.main.async {
// reject("ERROR", "No audio tracks available", nil)
// }
// return
// }
// let tracks = zip(trackNames, trackIndexes).map { name, index in
// return ["name": name, "index": index]
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
DispatchQueue.main.async {
if trackIndex == -1 {
print("Debug: Disabling subtitles")
// Disable subtitles
self.mediaPlayer?.currentVideoSubTitleIndex = -1
} else {
print("Debug: Setting subtitle track to index: \(trackIndex)")
// Set the subtitle track
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
}
// Print the result
if let currentIndex = self.mediaPlayer?.currentVideoSubTitleIndex {
print("Debug: Current subtitle track index after setting: \(currentIndex)")
} else {
print("Debug: Unable to retrieve current subtitle track index")
}
}
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
}
@@ -330,135 +236,6 @@ class VlcPlayerView: ExpoView {
print("Track not found for name: \(trackName)")
}
// @objc func getSubtitleTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// let count = mediaPlayer.numberOfSubtitlesTracks
// guard count > 0 else {
// DispatchQueue.main.async {
// reject("ERROR", "No subtitle tracks available", nil)
// }
// return
// }
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
// {
// for (index, name) in zip(indexes, names) {
// tracks.append(["name": name, "index": index.intValue])
// }
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
// TODO
// @objc func setSubtitleDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func setAudioDelay(_ delay: Int) {
// DispatchQueue.main.async {
// self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
// }
// }
// TODO
// @objc func takeSnapshot(_ path: String, width: Int, height: Int) {
// DispatchQueue.main.async { [weak self] in
// guard let self = self else { return }
// self.mediaPlayer?.saveVideoSnapshot(
// at: path, withWidth: Int32(width), andHeight: Int32(height))
// }
// }
// TODO
// @objc func setVideoCropGeometry(_ geometry: String?) {
// DispatchQueue.main.async {
// if let geometry = geometry, !geometry.isEmpty {
// self.currentGeometryCString = geometry.cString(using: .utf8)
// self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
// self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
// }
// } else {
// self.currentGeometryCString = nil
// self.mediaPlayer?.videoCropGeometry = nil
// }
// }
// }
// TODO
// @objc func getVideoCropGeometry() -> String? {
// guard let cString = mediaPlayer?.videoCropGeometry else {
// return nil
// }
// return String(cString: cString)
// }
// TODO
// @objc func setRate(_ rate: Float) {
// DispatchQueue.main.async {
// self.mediaPlayer?.rate = rate
// }
// }
// TODO
// @objc func nextChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.nextChapter()
// }
// }
// TODO
// @objc func previousChapter() {
// DispatchQueue.main.async {
// self.mediaPlayer?.previousChapter()
// }
// }
// TODO
// @objc func getChapters() -> [[String: Any]]? {
// guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
// let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
// as? [[String: Any]]
// else {
// return nil
// }
// return chapters.compactMap { chapter in
// guard let name = chapter[VLCChapterDescriptionName] as? String,
// let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
// let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
// else {
// return nil
// }
// return [
// "name": name,
// "timeOffset": timeOffset.doubleValue,
// "duration": duration.doubleValue,
// ]
// }
// }
private var isStopping: Bool = false
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
@@ -479,15 +256,11 @@ class VlcPlayerView: ExpoView {
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
if !isPaused {
pause()
}
}
@objc private func applicationWillEnterForeground() {
if !isPaused {
play()
}
@objc private func applicationDidBecomeActive() {
}
private func performStop(completion: (() -> Void)? = nil) {
@@ -509,42 +282,31 @@ class VlcPlayerView: ExpoView {
completion?()
}
private func getSubtitleOptions() -> [String: Any] {
return [
// Text scaling (100 is default, increase for larger text)
"sub-text-scale": "105",
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
// Text color (RRGGBB format, 16777215 is white)
"freetype-color": "16777215",
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
// Outline thickness (reduced from 2 to 1 for less border)
"freetype-outline-thickness": "1",
// Outline color (RRGGBB format, 0 is black)
"freetype-outline-color": "0",
// Text opacity (0-255, 255 is fully opaque)
"freetype-opacity": "255",
// Shadow opacity (increased from 128 to 180 for more shadow)
"freetype-shadow-opacity": "180",
// Shadow offset (increased from 2 to 3 for more pronounced shadow)
"freetype-shadow-offset": "3",
// Text alignment (0: center, 1: left, 2: right)
"sub-text-alignment": "0",
// Vertical margin (from bottom of the screen, in pixels)
"sub-margin-bottom": "50",
// Background opacity (0-255, 0 for no background)
"freetype-background-opacity": "64",
// Background color (RRGGBB format)
"freetype-background-color": "0",
]
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@@ -562,116 +324,64 @@ class VlcPlayerView: ExpoView {
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState()
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() {
DispatchQueue.main.async { [weak self] in
guard let self = self, let player = self.mediaPlayer else { return }
let currentState = player.state
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
}
// func seekToStartTime() {
// DispatchQueue.main.async { [weak self] in
// guard let self = self, let player = self.mediaPlayer else { return }
// if let startPosition = self.startPosition, startPosition > 0 {
// print("Debug: Seeking to start position: \(startPosition)")
// player.time = VLCTime(int: Int32(startPosition))
// // Ensure the player continues playing after seeking
// if !player.isPlaying {
// player.play()
// }
// }
// }
// }
func mediaPlayerTimeChanged(_ aNotification: Notification) {
DispatchQueue.main.async { [weak self] in
self?.updateVideoProgress()
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
}
private func updateVideoProgress() {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"] as? String, !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayerView: VLCMediaDelegate {
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
// // Implement if needed
// }
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
// DispatchQueue.main.async {
// let duration = aMedia.length.intValue
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
// }
// }
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState {

View File

@@ -16,6 +16,7 @@
"preset": "jest-expo"
},
"dependencies": {
"@bottom-tabs/react-navigation": "^0.7.1",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",
@@ -39,6 +40,7 @@
"expo-asset": "~10.0.10",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-brightness": "~12.0.1",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29",
@@ -69,8 +71,9 @@
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "^0.4.0",
"react-native-bottom-tabs": "^0.7.3",
"react-native-circular-progress": "^1.4.1",
"react-native-color-matrix-image-filters": "^7.0.1",
"react-native-compressor": "^1.9.0",
"react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.1",
@@ -82,6 +85,7 @@
"react-native-ios-utilities": "^4.5.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",

View File

@@ -1,6 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { writeToLog } from "@/utils/log";
import { useLog, writeToLog } from "@/utils/log";
import {
cancelAllJobs,
cancelJobById,
@@ -30,7 +30,7 @@ import {
import axios from "axios";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { atom, useAtom } from "jotai";
import React, {
createContext,
useCallback,
@@ -46,12 +46,17 @@ import * as Notifications from "expo-notifications";
import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system";
import * as Haptics from "expo-haptics";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
@@ -65,10 +70,12 @@ function useDownloadProvider() {
const [settings] = useSettings();
const router = useRouter();
const [api] = useAtom(apiAtom);
const { logs } = useLog();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useState<JobStatus[]>([]);
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const authHeader = useMemo(() => {
return api?.accessToken;
@@ -258,8 +265,11 @@ function useDownloadProvider() {
)
);
})
.done(async () => {
await saveDownloadedItemInfo(process.item);
.done(async (doneHandler) => {
await saveDownloadedItemInfo(
process.item,
doneHandler.bytesDownloaded
);
toast.success(`Download completed for ${process.item.Name}`, {
duration: 3000,
action: {
@@ -311,6 +321,7 @@ function useDownloadProvider() {
const fileExtension = mediaSource.TranscodingContainer;
const deviceId = await getOrSetDeviceId();
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
@@ -384,19 +395,25 @@ function useDownloadProvider() {
);
const deleteAllFiles = async (): Promise<void> => {
try {
await deleteLocalFiles();
removeDownloadedItemsFromStorage();
await cancelAllServerJobs();
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
toast.success("All files, folders, and jobs deleted successfully");
} catch (error) {
console.error("Failed to delete all files, folders, and jobs:", error);
toast.error("An error occurred while deleting files and jobs");
}
Promise.all([
deleteLocalFiles(),
removeDownloadedItemsFromStorage(),
cancelAllServerJobs(),
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
])
.then(() =>
toast.success("All files, folders, and jobs deleted successfully")
)
.catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error("An error occurred while deleting files and jobs");
});
};
const deleteLocalFiles = async (): Promise<void> => {
const forEveryDirectoryFile = async (
includeMMKV: boolean = true,
callback: (file: FileInfo) => void
) => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
@@ -404,25 +421,31 @@ function useDownloadProvider() {
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`;
const itemInfo = await FileSystem.getInfoAsync(itemPath);
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if (item == "mmkv" && !includeMMKV) continue;
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (itemInfo.exists) {
if (itemInfo.isDirectory) {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
} else {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
callback(itemInfo);
}
}
};
const removeDownloadedItemsFromStorage = (): void => {
try {
storage.delete("downloadedItems");
} catch (error) {
console.error("Failed to remove downloadedItems from storage:", error);
throw error;
}
const deleteLocalFiles = async (): Promise<void> => {
await forEveryDirectoryFile(false, (file) => {
console.warn("Deleting file", file.uri);
FileSystem.deleteAsync(file.uri, { idempotent: true });
});
};
const removeDownloadedItemsFromStorage = async () => {
// delete any saved images first
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
.then(() => storage.delete("downloadedItems"))
.catch((reason) => {
console.error("Failed to remove downloadedItems from storage:", reason);
throw reason;
});
};
const cancelAllServerJobs = async (): Promise<void> => {
@@ -430,7 +453,8 @@ function useDownloadProvider() {
throw new Error("No auth header available");
}
if (!settings?.optimizedVersionsServerUrl) {
throw new Error("No server URL configured");
console.error("No server URL configured");
return;
}
const deviceId = await getOrSetDeviceId();
@@ -490,6 +514,40 @@ function useDownloadProvider() {
}
};
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(
items.map((i) => {
if (i.Id) return deleteFile(i.Id);
return;
})
).then(() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
);
};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
await Promise.all(
downloadedFiles
?.filter((file) => file.item.Type == type)
?.flatMap((file) => {
const promises = [];
if (type == "Episode" && file.item.SeriesId)
promises.push(deleteFile(file.item.SeriesId));
promises.push(deleteFile(file.item.Id!));
return promises;
}) || []
);
};
const appSizeUsage = useMemo(async () => {
const sizes: number[] = [];
await forEveryDirectoryFile(true, (file) => {
if (file.exists) sizes.push(file.size);
});
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles]);
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
const downloadedItems = storage.getString("downloadedItems");
@@ -519,7 +577,7 @@ function useDownloadProvider() {
}
}
function saveDownloadedItemInfo(item: BaseItemDto) {
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: DownloadedItem[] = downloadedItems
@@ -546,6 +604,8 @@ function useDownloadProvider() {
deleteDownloadItemInfoFromDiskTmp(item.Id!);
storage.set("downloadedItems", JSON.stringify(items));
storage.set("downloadedItemSize-" + item.Id, size.toString());
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch();
} catch (error) {
@@ -556,17 +616,26 @@ function useDownloadProvider() {
}
}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
};
}
@@ -587,3 +656,16 @@ export function useDownload() {
}
return context;
}
export function bytesToReadable(bytes: number): string {
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(2)} MB`;
const kb = bytes / 1024;
if (kb >= 1) return `${kb.toFixed(2)} KB`;
return `${bytes.toFixed(2)} B`;
}

View File

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

View File

@@ -1,13 +1,10 @@
import { Bitrate } from "@/components/BitrateSelector";
import { settingsAtom } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import {
BaseItemDto,
MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
@@ -19,7 +16,6 @@ import React, {
useState,
} from "react";
import { apiAtom, userAtom } from "./JellyfinProvider";
import iosFmp4 from "@/utils/profiles/iosFmp4";
export type PlaybackType = {
item?: BaseItemDto | null;
@@ -124,25 +120,25 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
[api, user, settings, playSettings]
);
useEffect(() => {
const postCaps = async () => {
if (!api) return;
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
DeviceProfile: native as any,
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
};
// useEffect(() => {
// const postCaps = async () => {
// if (!api) return;
// await getSessionApi(api).postFullCapabilities({
// clientCapabilitiesDto: {
// AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
// DeviceProfile: native as any,
// IconUrl:
// "https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
// PlayableMediaTypes: ["Audio", "Video"],
// SupportedCommands: ["Play"],
// SupportsMediaControl: true,
// SupportsPersistentIdentifier: true,
// },
// });
// };
postCaps();
}, [settings, api]);
// postCaps();
// }, [settings, api]);
return (
<PlaySettingsContext.Provider

View File

@@ -0,0 +1,144 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useMemo,
useCallback,
} from "react";
import { Alert, AppState, AppStateStatus } from "react-native";
import { useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import {
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import native from "@/utils/profiles/native";
interface WebSocketProviderProps {
children: ReactNode;
}
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
}
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const deviceId = useMemo(() => {
return getOrSetDeviceId();
}, []);
const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken) return;
const protocol = api.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api.accessToken
}&deviceId=${deviceId}`;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
setIsConnected(false);
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
newWebSocket.close();
};
}, [api, deviceId]);
useEffect(() => {
const cleanup = connectWebSocket();
return cleanup;
}, [connectWebSocket]);
useEffect(() => {
if (!deviceId || !api || !api?.accessToken) return;
const init = async () => {
await getSessionApi(api).postFullCapabilities({
clientCapabilitiesDto: {
AppStoreUrl: "https://apps.apple.com/us/app/streamyfin/id6593660679",
IconUrl:
"https://raw.githubusercontent.com/retardgerman/streamyfinweb/refs/heads/main/public/assets/images/icon_new_withoutBackground.png",
PlayableMediaTypes: ["Audio", "Video"],
SupportedCommands: ["Play"],
SupportsMediaControl: true,
SupportsPersistentIdentifier: true,
},
});
};
init();
}, [api, deviceId]);
useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => {
if (state === "background" || state === "inactive") {
console.log("App moving to background, closing WebSocket...");
ws?.close();
} else if (state === "active") {
console.log("App coming to foreground, reconnecting WebSocket...");
connectWebSocket();
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
subscription.remove();
ws?.close();
};
}, [ws, connectWebSocket]);
return (
<WebSocketContext.Provider value={{ ws, isConnected }}>
{children}
</WebSocketContext.Provider>
);
};
export const useWebSocketContext = (): WebSocketContextType => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider"
);
}
return context;
};

View File

@@ -1,6 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import {JobStatus} from "@/utils/optimize-server";
import {processesAtom} from "@/providers/DownloadProvider";
import {useSettings} from "@/utils/atoms/settings";
export interface Job {
id: string;
@@ -13,8 +16,8 @@ export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
const updatedQueue = [...queue, job];
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => {
const updatedQueue = [...queue, ...job];
console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue);
},
@@ -24,14 +27,17 @@ export const queueActions = {
setProcessing: (processing: boolean) => void
) => {
const [job, ...rest] = queue;
setQueue(rest);
console.info("Processing job", job);
setProcessing(true);
// Excute the function assiociated with the job.
await job.execute();
// Allow job to execute so that it gets added as a processes first BEFORE updating new queue
try {
await job.execute();
} finally {
setQueue(rest);
}
console.info("Job done", job);
@@ -49,11 +55,13 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings();
useEffect(() => {
if (queue.length > 0 && !running) {
if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setRunning);
}
}, [queue, running, setQueue, setRunning]);
}, [processes, queue, running, setQueue, setRunning]);
};

View File

@@ -2,6 +2,7 @@ import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
export type DownloadQuality = "original" | "high" | "low";
@@ -74,6 +75,9 @@ export type Settings = {
optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux";
autoDownload: boolean;
showCustomMenuLinks: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
};
const loadSettings = (): Settings => {
@@ -103,6 +107,9 @@ const loadSettings = (): Settings => {
optimizedVersionsServerUrl: null,
downloadMethod: "remux",
autoDownload: false,
showCustomMenuLinks: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
};
try {

33
utils/download.ts Normal file
View File

@@ -0,0 +1,33 @@
import useImageStorage from "@/hooks/useImageStorage";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
const useDownloadHelper = () => {
const [api] = useAtom(apiAtom);
const { saveImage } = useImageStorage();
const saveSeriesPrimaryImage = async (item: BaseItemDto) => {
console.log(`Attempting to save primary image for item: ${item.Id}`);
if (
item.Type === "Episode" &&
item.SeriesId &&
!storage.getString(item.SeriesId)
) {
console.log(`Saving primary image for series: ${item.SeriesId}`);
await saveImage(
item.SeriesId,
getPrimaryImageUrlById({ api, id: item.SeriesId })
);
console.log(`Primary image saved for series: ${item.SeriesId}`);
} else {
console.log(`Skipping primary image save for item: ${item.Id}`);
}
};
return { saveSeriesPrimaryImage };
};
export default useDownloadHelper;

View File

@@ -10,8 +10,8 @@ interface PlaySettings {
item: BaseItemDto;
bitrate: (typeof BITRATES)[0];
mediaSource?: MediaSourceInfo | null;
audioIndex?: number | null;
subtitleIndex?: number | null;
audioIndex?: number | undefined;
subtitleIndex?: number | undefined;
}
export function getDefaultPlaySettings(

View File

@@ -1,5 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { storage } from "./mmkv";
import {useQuery} from "@tanstack/react-query";
import React, {createContext, useContext} from "react";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -17,6 +19,24 @@ const mmkvStorage = createJSONStorage(() => ({
}));
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
const LogContext = createContext<ReturnType<typeof useLogProvider> | null>(null);
const DownloadContext = createContext<ReturnType<
typeof useLogProvider
> | null>(null);
function useLogProvider() {
const { data: logs } = useQuery({
queryKey: ["logs"],
queryFn: async () => readFromLog(),
refetchInterval: 1000,
});
return {
logs
}
}
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
const newEntry: LogEntry = {
timestamp: new Date().toISOString(),
@@ -35,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => {
storage.set("logs", JSON.stringify(recentLogs));
};
export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data);
export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data);
export const readFromLog = (): LogEntry[] => {
const logs = storage.getString("logs");
return logs ? JSON.parse(logs) : [];
@@ -44,4 +67,22 @@ export const clearLogs = () => {
storage.delete("logs");
};
export function useLog() {
const context = useContext(LogContext);
if (context === null) {
throw new Error("useLog must be used within a LogProvider");
}
return context;
}
export function LogProvider({children}: { children: React.ReactNode }) {
const provider = useLogProvider();
return (
<LogContext.Provider value={provider}>
{children}
</LogContext.Provider>
)
}
export default logsAtom;

View File

@@ -1,18 +1,25 @@
import {
DeviceProfile
} from "@jellyfin/sdk/lib/generated-client/models";
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
export const chromecastProfile: DeviceProfile = {
Name: "Chromecast Video Profile",
Id: "chromecast-001",
MaxStreamingBitrate: 4000000, // 4 Mbps
MaxStaticBitrate: 4000000, // 4 Mbps
MaxStreamingBitrate: 8000000, // 8 Mbps
MaxStaticBitrate: 8000000, // 8 Mbps
MusicStreamingTranscodingBitrate: 384000, // 384 kbps
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
},
{
Type: "Audio",
Codec: "aac,mp3,flac,opus,vorbis",
},
],
DirectPlayProfiles: [
{
Container: "mp4,webm",
Container: "mp4",
Type: "Video",
VideoCodec: "h264,vp8,vp9",
VideoCodec: "h264",
AudioCodec: "aac,mp3,opus,vorbis",
},
{
@@ -34,89 +41,32 @@ export const chromecastProfile: DeviceProfile = {
],
TranscodingProfiles: [
{
Container: "ts",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3",
Context: "Streaming",
Protocol: "hls",
Context: "Streaming",
MaxAudioChannels: "2",
MinSegments: 2,
BreakOnNonKeyFrames: true,
Container: "ts",
VideoCodec: "h264, hevc",
AudioCodec: "aac,mp3,ac3",
CopyTimestamps: false,
EnableSubtitlesInManifest: true,
},
{
Container: "mp4",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac",
Type: "Audio",
Context: "Streaming",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Protocol: "http",
Context: "Streaming",
MaxAudioChannels: "2",
},
],
ContainerProfiles: [
{
Type: "Video",
Container: "mp4",
},
{
Type: "Video",
Container: "webm",
},
],
CodecProfiles: [
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "8",
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "41",
},
],
},
{
Type: "Video",
Codec: "vp9",
Conditions: [
{
Condition: "LessThanEqual",
Property: "VideoBitDepth",
Value: "10",
},
],
},
],
SubtitleProfiles: [
{
Format: "vtt",
Method: "Hls",
Method: "Encode",
},
{
Format: "vtt",
Method: "External",
Method: "Encode",
},
],
};

View File

@@ -1,180 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import BaseProfile from './base';
/**
* Device profile for Expo Video player on iOS 10
*/
export default {
...BaseProfile,
Name: 'Expo iOS 10 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: MediaTypes.Video
},
...BaseProfile.CodecProfiles
],
DirectPlayProfiles: [
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4,m4v',
Type: MediaTypes.Video,
VideoCodec: 'h264,vc1'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mov',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
Container: 'mp3',
Type: MediaTypes.Audio
},
{
Container: 'aac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'alac',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4a',
Type: MediaTypes.Audio
},
{
AudioCodec: 'alac',
Container: 'm4b',
Type: MediaTypes.Audio
},
{
Container: 'wav',
Type: MediaTypes.Audio
}
],
TranscodingProfiles: [
{
AudioCodec: 'aac',
BreakOnNonKeyFrames: true,
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Streaming',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'mp3',
Container: 'mp3',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac',
Container: 'aac',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'wav',
Container: 'wav',
Context: 'Static',
MaxAudioChannels: '6',
Protocol: 'http',
Type: MediaTypes.Audio
},
{
AudioCodec: 'aac,mp3',
BreakOnNonKeyFrames: true,
Container: 'ts',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'h264'
},
{
AudioCodec: 'aac,mp3,dca,dts,alac',
Container: 'mp4',
Context: 'Static',
Protocol: 'http',
Type: MediaTypes.Video,
VideoCodec: 'h264'
}
]
};

View File

@@ -1,49 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 11-12
*/
export default {
...iOSProfile,
Name: 'Expo iOS 12 Video Profile',
CodecProfiles: [
// iOS<13 only supports max h264 level 4.2 in ts containers
{
Codec: 'h264',
Conditions: [
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsAnamorphic',
Value: 'true'
},
{
Condition: 'EqualsAny',
IsRequired: false,
Property: 'VideoProfile',
Value: 'high|main|baseline|constrained baseline'
},
{
Condition: 'NotEquals',
IsRequired: false,
Property: 'IsInterlaced',
Value: 'true'
},
{
Condition: 'LessThanEqual',
IsRequired: false,
Property: 'VideoLevel',
Value: '42'
}
],
Container: 'ts',
Type: 'Video'
},
...iOSProfile.CodecProfiles
]
};

View File

@@ -1,35 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from '../../constants/MediaTypes';
import iOSProfile from './ios';
/**
* Device profile for Expo Video player on iOS 13+ with fMP4 support
*/
export default {
...iOSProfile,
Name: 'Expo iOS fMP4 Video Profile',
TranscodingProfiles: [
// Add all audio profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Audio),
// Add fMP4 profile
{
AudioCodec: 'aac,mp3,flac,alac',
BreakOnNonKeyFrames: true,
Container: 'mp4',
Context: 'Streaming',
MaxAudioChannels: '6',
MinSegments: '2',
Protocol: 'hls',
Type: MediaTypes.Video,
VideoCodec: 'hevc,h264'
},
// Add all video profiles from default profile
...iOSProfile.TranscodingProfiles.filter(profile => profile.Type === MediaTypes.Video)
]
};

View File

@@ -10,8 +10,8 @@ import MediaTypes from "../../constants/MediaTypes";
*/
export default {
Name: "1. Vlc Player",
MaxStaticBitrate: 20_000_000,
MaxStreamingBitrate: 20_000_000,
MaxStaticBitrate: 999_999_999,
MaxStreamingBitrate: 999_999_999,
CodecProfiles: [
{
Type: MediaTypes.Video,

View File

@@ -1,259 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import MediaTypes from "../../constants/MediaTypes";
/**
* Device profile for old phones (aka does not support HEVC)
*
* This file is a modified version of the original file.
*
* Link to original: https://github.com/jellyfin/jellyfin-expo/blob/e7b7e736a8602c94612917ef02de22f87c7c28f2/utils/profiles/ios.js#L4
*/
export default {
MaxStreamingBitrate: 3000000,
MaxStaticBitrate: 3000000,
MusicStreamingTranscodingBitrate: 256000,
DirectPlayProfiles: [
{
Container: "mp4,m4v",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mkv",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mov",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
{
Container: "mp3",
Type: "Audio",
},
{
Container: "aac",
Type: "Audio",
},
{
Container: "m4a",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "m4b",
AudioCodec: "aac",
Type: "Audio",
},
{
Container: "hls",
Type: "Video",
VideoCodec: "h264",
AudioCodec: "aac,mp3,mp2",
},
],
TranscodingProfiles: [
{
Container: "mp4",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Streaming",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp3",
Type: "Audio",
AudioCodec: "mp3",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "aac",
Type: "Audio",
AudioCodec: "aac",
Context: "Static",
Protocol: "http",
MaxAudioChannels: "2",
},
{
Container: "mp4",
Type: "Video",
AudioCodec: "aac,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
{
Container: "ts",
Type: "Video",
AudioCodec: "aac,mp3,mp2",
VideoCodec: "h264",
Context: "Streaming",
Protocol: "hls",
MaxAudioChannels: "2",
MinSegments: "1",
BreakOnNonKeyFrames: true,
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "60",
IsRequired: false,
},
],
},
],
ContainerProfiles: [],
CodecProfiles: [
{
Type: "VideoAudio",
Codec: "aac",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "VideoAudio",
Conditions: [
{
Condition: "Equals",
Property: "IsSecondaryAudio",
Value: "false",
IsRequired: false,
},
],
},
{
Type: "Video",
Codec: "h264",
Conditions: [
{
Condition: "NotEquals",
Property: "IsAnamorphic",
Value: "true",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoProfile",
Value: "high|main|baseline|constrained baseline",
IsRequired: false,
},
{
Condition: "EqualsAny",
Property: "VideoRangeType",
Value: "SDR",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoLevel",
Value: "52",
IsRequired: false,
},
{
Condition: "NotEquals",
Property: "IsInterlaced",
Value: "true",
IsRequired: false,
},
],
},
{
Type: "Video",
Conditions: [
{
Condition: "LessThanEqual",
Property: "Width",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "Height",
Value: "960",
IsRequired: false,
},
{
Condition: "LessThanEqual",
Property: "VideoFramerate",
Value: "65",
IsRequired: false,
},
],
},
],
SubtitleProfiles: [
{
Method: "Encode",
},
],
};