Merge branch 'develop' into feature/newarch

This commit is contained in:
Gauvain
2025-09-26 20:52:32 +02:00
committed by GitHub
93 changed files with 1304 additions and 763 deletions

60
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,60 @@
# Copilot Instructions for Streamyfin
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
## Main Technologies
- React Native (Expo)
- TypeScript
- React Query
- Jotai (state management)
- Jellyfin SDK (TypeScript)
- BiomeJS (code formatting/linting)
- EAS (Expo Application Services)
- Shell scripting (for automation)
- GitHub Actions (CI/CD)
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
- `README.md` Project documentation
## Coding Conventions
- Use TypeScript for all new code.
- Prefer functional React components.
- Use hooks for state and side effects.
- Use Jotai for global state.
- Use React Query for data fetching/caching.
- Use BiomeJS for formatting and linting.
- Follow the established folder structure for screens/components.
## API Usage
- Use the Jellyfin SDK for all server interactions.
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
- For navigation, use `expo-router`.
## Commit Messages
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
- Example: `feat(player): add Chromecast support`
## Special Instructions
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
---
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

View File

@@ -39,7 +39,7 @@ jobs:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -53,7 +53,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.gradle/caches
@@ -70,7 +70,7 @@ jobs:
fi
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}

View File

@@ -44,7 +44,7 @@ jobs:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}

View File

@@ -32,7 +32,7 @@ jobs:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.bun/install/cache

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3.30.4

View File

@@ -65,6 +65,7 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
@@ -106,7 +107,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '24.x'

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '24.x'
cache: 'npm'

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.35.0",
"version": "0.36.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 66,
"versionCode": 69,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -49,6 +49,7 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json"
},
"plugins": [
@@ -122,7 +123,7 @@
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"backgroundColor": "#010101",
"image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100
}

View File

@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const [_settings, _updateSettings, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
return (
<DisabledSetting

View File

@@ -21,7 +21,7 @@ export default function page() {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

@@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
@@ -53,7 +53,10 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data],
@@ -98,9 +101,7 @@ export default function page() {
}}
/>
}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -8,14 +8,10 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -51,7 +47,10 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data],
@@ -62,7 +61,7 @@ export default function page() {
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
r.backdropPath,
"w1920_and_h800_multi_faces",
),
)
@@ -92,9 +91,7 @@ export default function page() {
{name}
</Text>
}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -139,7 +139,15 @@ const Page: React.FC = () => {
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
const isAnime = useMemo(
() =>
@@ -277,12 +285,16 @@ const Page: React.FC = () => {
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params:
mediaType === MediaType.MOVIE
? { id: details?.mediaInfo.jellyfinMediaId }
: { id: details?.mediaInfo.jellyfinMediaId },
});
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />

View File

@@ -10,10 +10,6 @@ import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
@@ -106,9 +102,7 @@ export default function page() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/>
);
}

View File

@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { actorId } = local as { actorId: string };
const { personId } = local as { personId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", actorId],
queryKey: ["item", personId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: actorId,
itemId: personId,
}),
enabled: !!actorId && !!api,
enabled: !!personId && !!api,
staleTime: 60,
});
@@ -50,7 +50,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
personIds: [personId],
startIndex: pageParam,
limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, actorId],
[api, user?.Id, personId],
);
const backdropUrl = useMemo(
@@ -131,7 +131,7 @@ const page: React.FC = () => {
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
queryKey={["actor", "movies", personId]}
/>
<View className='h-12' />
</View>

View File

@@ -77,7 +77,13 @@ const Page = () => {
} else {
_setSortBy([SortByOption.SortName]);
}
}, []);
}, [
libraryId,
sortOrderPreference,
sortByPreference,
_setSortOrder,
_setSortBy,
]);
const setSortBy = useCallback(
(sortBy: SortByOption[]) => {
@@ -87,7 +93,7 @@ const Page = () => {
}
_setSortBy(sortBy);
},
[libraryId, sortByPreference],
[libraryId, sortByPreference, setSortByPreference, _setSortBy],
);
const setSortOrder = useCallback(
@@ -101,7 +107,7 @@ const Page = () => {
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference],
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
);
const nrOfCols = useMemo(() => {

View File

@@ -9,7 +9,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();

View File

@@ -19,7 +19,7 @@ export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();

View File

@@ -71,7 +71,7 @@ export default function search() {
const [api] = useAtom(apiAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(

View File

@@ -27,7 +27,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator);
export default function TabLayout() {
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();
const router = useRouter();

View File

@@ -2,6 +2,7 @@ import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
@@ -97,7 +98,7 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const [_settings] = useSettings(null);
useSettings();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -264,12 +265,7 @@ export default function page() {
if (isPlaying) {
await videoRef.current?.pause();
playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
currentPlayStateInfo() as PlaybackProgressInfo,
);
} else {
videoRef.current?.play();
@@ -387,12 +383,7 @@ export default function page() {
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
currentPlayStateInfo() as PlaybackProgressInfo,
);
},
[
@@ -499,12 +490,7 @@ export default function page() {
setIsPlaying(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
@@ -515,12 +501,7 @@ export default function page() {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await deactivateKeepAwake();
@@ -748,7 +729,6 @@ export default function page() {
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}

View File

@@ -20,8 +20,8 @@ import {
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeInfoLog,
writeToLog,
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
@@ -84,19 +84,19 @@ SplashScreen.setOptions({
fade: true,
});
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() {
useEffect(() => {
if (Platform.isTV) return;
let isMounted = true;
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
@@ -106,15 +106,8 @@ function useNotificationObserver() {
},
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
@@ -230,7 +223,7 @@ const queryClient = new QueryClient({
});
function Layout() {
const [settings] = useSettings(null);
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
@@ -269,6 +262,15 @@ function Layout() {
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
const granted = await checkAndRequestPermissions();
@@ -308,38 +310,42 @@ function Layout() {
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
writeInfoLog(`Notification ${title} opened`, data);
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
// summarized season notification for multiple episodes. Bring them to series season
let url: any;
const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
switch (type) {
case "movie":
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
break;
case "episode":
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
// We just clicked a notification for an individual episode.
if (itemId) {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season
} else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
url = `/(auth)/(tabs)/home/series/${seriesId}`;
}
break;
}
}
break;
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
}
},
);

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"files": {
"includes": [
"**/*",

View File

@@ -87,7 +87,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.2.2",
"@biomejs/biome": "^2.2.4",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",
@@ -302,23 +302,23 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@biomejs/biome": ["@biomejs/biome@2.2.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.2", "@biomejs/cli-darwin-x64": "2.2.2", "@biomejs/cli-linux-arm64": "2.2.2", "@biomejs/cli-linux-arm64-musl": "2.2.2", "@biomejs/cli-linux-x64": "2.2.2", "@biomejs/cli-linux-x64-musl": "2.2.2", "@biomejs/cli-win32-arm64": "2.2.2", "@biomejs/cli-win32-x64": "2.2.2" }, "bin": { "biome": "bin/biome" } }, "sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6ePfbCeCPryWu0CXlzsWNZgVz/kBEvHiPyNpmViSt6A2eoDf4kXs3YnwQPzGjy8oBgQulrHcLnJL0nkCh80mlQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tn4JmVO+rXsbRslml7FvKaNrlgUeJot++FkvYIhl1OkslVCofAtS35MPlBMhXgKWF9RNr9cwHanrPTUUXcYGag=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-JfrK3gdmWWTh2J5tq/rcWCOsImVyzUnOS2fkjhiYKCQ+v8PqM+du5cfB7G1kXas+7KQeKSWALv18iQqdtIMvzw=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/MhYg+Bd6renn6i1ylGFL5snYUn/Ct7zoGVKhxnro3bwekiZYE8Kl39BSb0MeuqM+72sThkQv4TnNubU9njQRw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ogb+77edO5LEP/xbNicACOWVLt8mgC+E1wmpUakr+O4nKwLt9vXe74YNuT3T1dUBxC/SnrVmlzZFC7kQJEfquQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZCLXcZvjZKSiRY/cFANKg+z6Fhsf9MHOzj+NrDQcM+LbqYRT97LyCLWy2AS+W2vP+i89RyRM+kbGpUzbRTYWig=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-wBe2wItayw1zvtXysmHJQoQqXlTzHSpQRyPpJKiNIR21HzH/CrZRDFic1C1jDdp+zAPtqhNExa0owKMbNwW9cQ=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.9.2", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-IZZKllcaqCGsKIgeXmYFGU95IXxbBpXtwKws4Lg2GJw/qqAYYsPFEl0JBvnymSD7G1zkHYEilg5UHuTd0NmX7A=="],
@@ -550,7 +550,7 @@
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
"@react-native-menu/menu": ["@react-native-menu/menu@1.2.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-63NU9NHd/KcBa7lhhV69xQmY3ORUxjMRmlzTY/qrY56GHnuKX+16dvUpKAuRSPK5kebBuRw5X4MFe2L9JHUlWA=="],
"@react-native-menu/menu": ["@react-native-menu/menu@1.2.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-sEfiVIivsa0lSelFm9Wbm/RAi+XoEHc75GGhjwvSrj9KSCVvNNXwr9F8l42e1t/lzYvVYzmkYxLG6VKxrDYJiw=="],
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.3", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-9N3scEwrNtgbXRr9Pmg5H7mlsNKdbS4Jovt92aaWX6ggj3bx5zmHZHTtbqFdiNWC551LGcr79i/S/xDKE6CqNg=="],

View File

@@ -61,7 +61,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } =

View File

@@ -54,7 +54,7 @@ interface ItemContentProps {
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();

View File

@@ -36,7 +36,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv || (item.MediaStreams && item.MediaStreams.length <= 1)) return null;
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
return (
<View className='flex shrink' style={{ minWidth: 75 }}>

View File

@@ -5,7 +5,7 @@ import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";

View File

@@ -67,7 +67,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(

View File

@@ -44,7 +44,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { ItemCardText } from "./ItemCardText";

View File

@@ -10,6 +10,7 @@ import {
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -17,7 +18,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails;
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;
@@ -39,7 +40,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
const autoApprove = useMemo(() => {
return (

View File

@@ -1,8 +1,5 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
@@ -14,10 +11,7 @@ interface Props extends TouchableOpacityProps {
isOffline?: boolean;
}
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string,
) => {
export const itemRouter = (item: BaseItemDto, from: string) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -26,8 +20,8 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
}
if (item.Type === "BoxSet") {
@@ -49,6 +43,48 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
export const getItemNavigation = (item: BaseItemDto, _from: string) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return {
pathname: "/livetv" as const,
};
}
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
params: { id: item.Id! },
};
}
if (item.Type === "Person") {
return {
pathname: "/persons/[personId]" as const,
params: { personId: item.Id! },
};
}
if (item.Type === "BoxSet" || item.Type === "UserView") {
return {
pathname: "/collections/[collectionId]" as const,
params: { collectionId: item.Id! },
};
}
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
// Default case - items page
return {
pathname: "/items/page" as const,
params: { id: item.Id! },
};
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
isOffline = false,
@@ -61,7 +97,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
const showActionSheet = useCallback(() => {
if (
@@ -107,12 +143,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
let url = itemRouter(item, from);
if (isOffline) {
url += `&offline=true`;
// For offline mode, we still need to use query params
const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as any);
return;
}
// @ts-expect-error
router.push(url);
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
}}
{...props}
>

View File

@@ -1,27 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
type ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
import { DownloadCard } from "./DownloadCard";
interface ActiveDownloadsProps extends ViewProps {}
@@ -52,163 +34,3 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
</View>
);
}
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
/>
)}
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
<View className='ml-auto flex flex-row items-center space-x-2'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-2 rounded-full bg-yellow-600'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-2 rounded-full bg-green-600'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 rounded-full bg-red-600'
>
<Ionicons name='close' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,198 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process.progress]);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width:
sanitizedProgress > 0
? `${Math.max(5, sanitizedProgress)}%`
: "5%",
}}
/>
)}
{/* Action buttons in top right corner */}
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
</View>
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1 flex-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -21,12 +21,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { itemRouter } from "../common/TouchableItemRouter";
import { getItemNavigation } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
const progress = useSharedValue<number>(0);
@@ -146,16 +146,15 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}, [item]);
const segments = useSegments();
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback();
// @ts-expect-error
if (url) router.push(url);
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
}, [item, from]);
const tap = Gesture.Tap()

View File

@@ -0,0 +1,131 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
StyleSheet,
Text,
type TextInputProps,
View,
type ViewStyle,
} from "react-native";
interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
value: string;
onChangeText: (text: string) => void;
length?: number;
autoFocus?: boolean;
style?: StyleProp<ViewStyle>;
}
export interface PinInputRef {
focus: () => void;
}
const PinInputComponent = React.forwardRef<PinInputRef, PinInputProps>(
(props, ref) => {
const {
value,
onChangeText,
length = 6,
style,
autoFocus,
...rest
} = props;
const inputRef = useRef<any>(null);
const activeIndex = value.length;
const handlePress = useCallback(() => {
inputRef.current?.focus();
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
return (
<View style={[styles.container, style]}>
<BottomSheetTextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
keyboardType='number-pad'
maxLength={length}
style={styles.hiddenInput}
autoFocus={autoFocus}
{...rest}
/>
<View style={styles.cells} onTouchStart={handlePress}>
{Array(length)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
styles.cell,
i === activeIndex && styles.activeCell,
i === activeIndex - 1 && styles.filledCell,
]}
>
<Text style={styles.digit}>{value[i]}</Text>
{i === activeIndex && <View style={styles.cursor} />}
</View>
))}
</View>
</View>
);
},
);
PinInputComponent.displayName = "PinInput";
export const PinInput = PinInputComponent;
const styles = StyleSheet.create({
container: {
width: "100%",
},
hiddenInput: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
cells: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
cell: {
width: 40,
height: 48,
borderWidth: 1,
borderColor: "#374151",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1F2937",
},
activeCell: {
borderColor: "#6366F1",
},
filledCell: {
borderColor: "#4B5563",
},
digit: {
fontSize: 24,
color: "white",
fontWeight: "500",
},
cursor: {
position: "absolute",
width: 2,
height: 24,
backgroundColor: "#6366F1",
},
});

View File

@@ -22,7 +22,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const segments = useSegments();
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (

View File

@@ -16,13 +16,12 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
// @ts-expect-error - Dynamic pathname for jellyseerr routing
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[slide],

View File

@@ -13,13 +13,12 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2] || "(home)";
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
// @ts-expect-error - Dynamic pathname for jellyseerr routing
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[slide],

View File

@@ -11,16 +11,12 @@ import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
@@ -69,7 +65,9 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results),
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id",
),
[data],
@@ -86,12 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => (
<JellyseerrPoster
item={item as MovieResult | TvResult}
key={item?.id}
/>
)}
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
/>
)
);

View File

@@ -8,7 +8,14 @@ import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
profileName: string;
canRemove: boolean;
};
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({
@@ -67,9 +74,15 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
<Slide
{...props}
slide={slide}
data={requests.results}
data={
requests.results.map((item) => ({
...item,
profileName: item.profileName ?? "Unknown",
canRemove: Boolean(item.canRemove),
})) as ExtendedMediaRequest[]
}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
renderItem={(item: ExtendedMediaRequest) => (
<RequestCard request={item} />
)}
/>

View File

@@ -41,7 +41,7 @@ const icons: Record<CollectionType, IconName> = {
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const { t } = useTranslation();

View File

@@ -12,7 +12,7 @@ import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";

View File

@@ -20,6 +20,7 @@ import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -27,7 +28,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends ViewProps {
item?: MovieResult | TvResult | MovieDetails | TvDetails;
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;

View File

@@ -10,9 +10,8 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster";
interface Props extends ViewProps {
@@ -24,19 +23,21 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
const from = segments[2];
const from = (segments as string[])[2];
const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = [];
const people: Record<string, BaseItemPerson> = {};
item?.People?.forEach((person) => {
const existingPerson = people.find((p) => p.Id === person.Id);
if (!person.Id) return;
const existingPerson = people[person.Id];
if (existingPerson) {
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
} else {
people.push(person);
people[person.Id] = person;
}
});
return people;
return Object.values(people);
}, [item?.People]);
if (!from) return null;
@@ -54,9 +55,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
const url = itemRouter(i, from);
// @ts-expect-error
router.push(url);
if (i.Id) {
router.push({
pathname: "/persons/[personId]",
params: { personId: i.Id },
});
}
}}
className='flex flex-col w-28'
>

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";

View File

@@ -11,7 +11,7 @@ import { orderBy } from "lodash";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";

View File

@@ -1,18 +1,17 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "../common/HorrizontalScroll";
} from "../common/HorizontalScroll";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
@@ -42,11 +41,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
return item?.SeasonId;
}, [item]);
const {
data: episodes,
isLoading,
isFetching,
} = useQuery({
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
@@ -61,6 +56,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
userId: user.Id,
seasonId: seasonId || undefined,
seriesId: item.SeriesId,
enableUserData: true,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
@@ -74,48 +70,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
enabled: !!api && !!user?.Id && !!seasonId,
});
/**
* Prefetch previous and next episode
*/
const queryClient = useQueryClient();
useEffect(() => {
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
return;
}
const previousId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! - 1,
)?.Id;
if (previousId) {
queryClient.prefetchQuery({
queryKey: ["item", previousId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: previousId,
}),
staleTime: 60 * 1000 * 5,
});
}
const nextId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! + 1,
)?.Id;
if (nextId) {
queryClient.prefetchQuery({
queryKey: ["item", nextId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: nextId,
}),
staleTime: 60 * 1000 * 5,
});
}
}, [episodes, api, user?.Id, item]);
useEffect(() => {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
@@ -132,7 +86,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
ref={scrollRef}
data={episodes}
extraData={item}
loading={loading || isLoading || isFetching}
loading={loading || isPending}
renderItem={(_item, _idx) => (
<TouchableOpacity
key={_item.Id}

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,6 @@ import {
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text";
@@ -74,7 +73,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
@@ -87,7 +86,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
seasonId: selectedSeasonId,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
fields: ["Overview", "Trickplay"],
});
if (res.data.TotalRecordCount === 0)
@@ -101,25 +100,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
const queryClient = useQueryClient();
useEffect(() => {
for (const 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]);
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
@@ -165,7 +145,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
) : null}
</View>
<View className='px-4 flex flex-col mt-4'>
{isFetching ? (
{isPending ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,

View File

@@ -12,7 +12,7 @@ interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
if (isTv) return null;

View File

@@ -17,7 +17,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const [_, __, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();

View File

@@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
return (
<View {...props}>
<ListGroup title={"Chromecast"}>

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const [settings, _updateSettings] = useSettings(null);
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();

View File

@@ -7,13 +7,13 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true,
pluginSettings?.autoDownload?.locked === true,
[pluginSettings],
);

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const GestureControls: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>

View File

@@ -64,13 +64,7 @@ export const HomeIndex = () => {
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
_updateSettings,
_pluginSettings,
_setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(null);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
@@ -80,7 +74,12 @@ export const HomeIndex = () => {
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus();
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
// Only invalidate cache when transitioning from offline to online
@@ -126,7 +125,7 @@ export const HomeIndex = () => {
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if (segments[2] === "(home)")
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
});
@@ -314,10 +313,10 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) {
const id = section.items?.title || `section-${index}`;
const id = section.title || `section-${index}`;
ss.push({
title: t(`${id}`),
queryKey: ["home", id],
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
@@ -364,13 +363,28 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
if (isConnected === false) {
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
// No network connection
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{t("home.no_internet")}</Text>
<Text className='text-center opacity-70'>
{t("home.no_internet_message")}
</Text>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
@@ -384,6 +398,7 @@ export const HomeIndex = () => {
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={retryCheck}
@@ -396,9 +411,9 @@ export const HomeIndex = () => {
}
>
{retryLoading ? (
<ActivityIndicator size={"small"} color={"white"} />
<ActivityIndicator size='small' color='white' />
) : (
"Retry"
t("home.retry")
)}
</Button>
</View>

View File

@@ -20,7 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const [settings, updateSettings, _pluginSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined

View File

@@ -28,7 +28,7 @@ export const useMedia = () => {
};
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const [settings, updateSettings] = useSettings(null);
const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo(
() =>

View File

@@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem";
export const OtherSettings: React.FC = () => {
const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();

View File

@@ -6,7 +6,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const PluginSettings = () => {
const [settings, _updateSettings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();

View File

@@ -2,7 +2,6 @@ import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
@@ -10,17 +9,19 @@ import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, View, type ViewProps } from "react-native";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
@@ -73,11 +74,17 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
}
}, [api, user, quickConnectCode]);
if (isTv) return null;
return (
<View {...props}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem
onPress={() => bottomSheetModalRef?.current?.present()}
onPress={() => {
// Reset the code when opening the sheet
setQuickConnectCode("");
bottomSheetModalRef?.current?.present();
}}
title={t("home.settings.quick_connect.authorize_button")}
textColor='blue'
/>
@@ -93,6 +100,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -102,16 +112,17 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
</Text>
</View>
<View className='flex flex-col space-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t(
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'>
<Text className='text-neutral-400 text-center'>
{t(
"home.settings.quick_connect.enter_the_quick_connect_code",
)}
placeholderTextColor='#9CA3AF'
value={quickConnectCode}
</Text>
<PinInput
value={quickConnectCode || ""}
onChangeText={setQuickConnectCode}
style={{ paddingHorizontal: 16 }}
autoFocus
/>
</View>
</View>

View File

@@ -20,7 +20,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const [_, __, pluginSettings] = useSettings(null);
const { pluginSettings } = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />,
};
const routes = ["actors/[actorId]", "items/page", "series/[id]"];
const routes = ["persons/[personId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -88,7 +88,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickplayInfo,
time,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return (

View File

@@ -30,7 +30,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
handleSkipBackward,
handleSkipForward,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return (

View File

@@ -16,7 +16,7 @@ export interface ContinueWatchingOverlayProps {
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();
return settings.autoPlayEpisodeCount >=

View File

@@ -116,7 +116,7 @@ export const Controls: FC<Props> = ({
api = null,
downloadedFiles = undefined,
}) => {
const [settings, updateSettings] = useSettings(api);
const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
@@ -379,8 +379,7 @@ export const Controls: FC<Props> = ({
console.log("queryParams", queryParams);
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}` as any);
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
);

View File

@@ -11,7 +11,7 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
} from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import {

View File

@@ -31,7 +31,7 @@ export const GestureOverlay = ({
onSkipForward,
onSkipBackward,
}: Props) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHaptic = useHaptic("light");
const [feedback, setFeedback] = useState<FeedbackState>({

View File

@@ -70,7 +70,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setVideoAspectRatio,
setVideoScaleFactor,
}) => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();

View File

@@ -95,8 +95,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
playbackPosition: playbackPosition,
}).toString();
//@ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}` as any);
};
const setTrackParams = (

View File

@@ -51,8 +51,7 @@ const DropdownView = () => {
bitrateValue: bitrate.toString(),
playbackPosition: playbackPosition,
}).toString();
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
router.replace(`player/direct-player?${queryParams}` as any);
},
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
);

View File

@@ -20,7 +20,7 @@ export function useVideoNavigation({
seek,
play,
}: UseVideoNavigationProps) {
const [settings] = useSettings(null);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const wasPlayingRef = useRef(false);

View File

@@ -26,13 +26,6 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"preview": {
"environment": "development",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"development-simulator": {
"environment": "development",
"developmentClient": true,
@@ -44,16 +37,22 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"preview": {
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"production": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"buildType": "apk",
"image": "latest"
@@ -61,7 +60,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.35.0",
"channel": "0.36.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -14,7 +14,7 @@ export type HapticFeedbackType =
| "error";
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings(null);
const { settings } = useSettings();
const isTv = Platform.isTV;
const isDisabled =
isTv ||

View File

@@ -14,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { t } from "i18next";
import { useCallback, useMemo } from "react";
import { toast } from "sonner-native";
import { defaultValues, Settings } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import {
IssueStatus,
@@ -40,6 +40,7 @@ import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/a
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
CombinedCredit,
PersonCreditCast,
PersonDetails,
} from "@/utils/jellyseerr/server/models/Person";
import type {
@@ -386,7 +387,7 @@ export class JellyseerrApi {
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data,
);
if (error.status === 403) {
if (error.response?.status === 403) {
clearJellyseerrStorageData();
}
return Promise.reject(error);
@@ -416,10 +417,8 @@ export class JellyseerrApi {
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = (
settings: Settings = defaultValues,
updateSettings: (update: Partial<Settings>) => void = () => {},
) => {
export const useJellyseerr = () => {
const { settings, updateSettings } = useSettings();
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const queryClient = useQueryClient();
@@ -468,54 +467,52 @@ export const useJellyseerr = (
[jellyseerrApi],
);
const isJellyseerrResult = (
const isJellyseerrMovieOrTvResult = (
items: any | null | undefined,
): items is Results => {
): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
Object.values(MediaType).includes(items.mediaType as MediaType)
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
);
};
const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return isJellyseerrResult(item)
return isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.title
: item?.name
: item?.mediaInfo.mediaType === MediaType.MOVIE
: item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.title
: (item as TvDetails)?.name;
};
const getYear = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return new Date(
(isJellyseerrResult(item)
(isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.releaseDate
: item?.firstAirDate
: item?.mediaInfo.mediaType === MediaType.MOVIE
: item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.releaseDate
: (item as TvDetails)?.firstAirDate) || "",
)?.getFullYear?.();
};
const getMediaType = (
item?: TvResult | TvDetails | MovieResult | MovieDetails,
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
): MediaType => {
return isJellyseerrResult(item)
? item.mediaType === "movie"
? MediaType.MOVIE
: MediaType.TV
return isJellyseerrMovieOrTvResult(item)
? (item.mediaType as MediaType)
: item?.mediaInfo?.mediaType;
};
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.region || "US",
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
);
@@ -528,7 +525,7 @@ export const useJellyseerr = (
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
isJellyseerrMovieOrTvResult,
getTitle,
getYear,
getMediaType,

View File

@@ -1,30 +1,58 @@
import NetInfo from "@react-native-community/netinfo";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
async function checkApiReachable(basePath?: string): Promise<boolean> {
if (!basePath) return false;
try {
const response = await fetch(basePath, { method: "HEAD" });
return response.ok;
} catch {
return false;
}
}
export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(false);
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [api] = useAtom(apiAtom);
const validateConnection = useCallback(async () => {
if (!api?.basePath) return false;
const reachable = await checkApiReachable(api.basePath);
setServerConnected(reachable);
return reachable;
}, [api?.basePath]);
// Manual check (optional)
const retryCheck = useCallback(async () => {
setLoading(true);
const state = await NetInfo.fetch();
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
await validateConnection();
setLoading(false);
}, []);
}, [validateConnection]);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
const unsubscribe = NetInfo.addEventListener(async (state) => {
setIsConnected(!!state.isConnected);
if (state.isConnected) {
await validateConnection();
} else {
setServerConnected(false);
}
});
// Initial state
// Initial check: wait for NetInfo first
NetInfo.fetch().then((state) => {
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
if (state.isConnected) {
validateConnection();
} else {
setServerConnected(false);
}
});
return () => unsubscribe();
}, []);
}, [validateConnection]);
return { isConnected, loading, retryCheck };
return { isConnected, serverConnected, loading, retryCheck };
}

View File

@@ -1,4 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type {
BaseItemDto,
PlaybackProgressInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
@@ -141,13 +144,10 @@ export const usePlaybackManager = ({
* @param positionTicks The current playback position in ticks.
*/
const reportPlaybackProgress = async (
itemId: string,
positionTicks: number,
metadata?: {
AudioStreamIndex: number;
SubtitleStreamIndex: number;
},
playbackProgressInfo: PlaybackProgressInfo,
) => {
const positionTicks = playbackProgressInfo.PositionTicks || 0;
const itemId = playbackProgressInfo.ItemId!;
const localItem = getDownloadedItemById(itemId);
// Handle local state update for downloaded items
@@ -192,14 +192,7 @@ export const usePlaybackManager = ({
if (isOnline && api) {
try {
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: {
ItemId: itemId,
PositionTicks: Math.floor(positionTicks),
...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }),
...(metadata && {
SubtitleStreamIndex: metadata.SubtitleStreamIndex,
}),
},
playbackProgressInfo,
});
} catch (error) {
console.error("Failed to report playback progress", error);

View File

@@ -14,7 +14,7 @@
"android:tv": "cross-env EXPO_TV=1 expo run:android",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
"prepare": "husky",
"typecheck": "tsc -p tsconfig.json --noEmit | grep -v \"utils/jellyseerr\"",
"typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .",
@@ -105,7 +105,7 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.2.2",
"@biomejs/biome": "^2.2.4",
"@react-native-community/cli": "^20.0.0",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12",

View File

@@ -4,6 +4,7 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { atom, useAtom } from "jotai";
import { throttle } from "lodash";
@@ -87,9 +88,72 @@ function useDownloadProvider() {
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
const successHapticFeedback = useHaptic("success");
// Generate notification content based on item type
const getNotificationContent = useCallback(
(item: BaseItemDto, isSuccess: boolean) => {
if (item.Type === "Episode") {
const season = item.ParentIndexNumber
? String(item.ParentIndexNumber).padStart(2, "0")
: "??";
const episode = item.IndexNumber
? String(item.IndexNumber).padStart(2, "0")
: "??";
const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else if (item.Type === "Movie") {
const year = item.ProductionYear ? ` (${item.ProductionYear})` : "";
const subtitle = `${item.Name}${year}`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else {
// Fallback for other types
return {
title: isSuccess
? t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
})
: t("home.downloads.toasts.download_failed_for_item", {
item: item.Name,
}),
body: item.Name || "Unknown item",
};
}
},
[t],
);
// Send local notification for download events
const sendDownloadNotification = useCallback(
async (title: string, body: string, data?: Record<string, any>) => {
if (Platform.isTV) return;
try {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
...(Platform.OS === "android" && { channelId: "downloads" }),
},
trigger: null, // Show immediately
});
} catch (error) {
console.error("Failed to send notification:", error);
}
},
[],
);
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
@@ -418,6 +482,21 @@ function useDownloadProvider() {
}
await saveDownloadsDatabase(db);
// Send native notification for successful download
const successNotification = getNotificationContent(
process.item,
true,
);
await sendDownloadNotification(
successNotification.title,
successNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_completed",
},
);
toast.success(
t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name,
@@ -425,8 +504,25 @@ function useDownloadProvider() {
);
removeProcess(process.id);
})
.error((error: any) => {
.error(async (error: any) => {
console.error("Download error:", error);
// Send native notification for failed download
const failureNotification = getNotificationContent(
process.item,
false,
);
await sendDownloadNotification(
failureNotification.title,
failureNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_failed",
error: error?.message || "Unknown error",
},
);
toast.error(
t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name,
@@ -435,7 +531,7 @@ function useDownloadProvider() {
removeProcess(process.id);
});
},
[authHeader],
[authHeader, sendDownloadNotification, getNotificationContent],
);
const manageDownloadQueue = useCallback(() => {

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.35.0" },
clientInfo: { name: "Streamyfin", version: "0.36.0" },
deviceInfo: {
name: deviceName,
id,
@@ -79,24 +79,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
const [
_settings,
_updateSettings,
_pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(api);
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(
_settings || {},
_updateSettings,
);
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const headers = useMemo(() => {
if (!deviceId) return {};
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.35.0"`,
}, DeviceId="${deviceId}", Version="0.36.0"`,
};
}, [deviceId]);
@@ -383,7 +374,7 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
useEffect(() => {
if (loaded === false) return;
const inAuthGroup = segments[0] === "(auth)";
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) {
console.log("Redirected to login");

256
scripts/typecheck.js Normal file
View File

@@ -0,0 +1,256 @@
const { execFileSync } = require("node:child_process");
const process = require("node:process");
// Enhanced ANSI color codes and styles
const colors = {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
underline: "\x1b[4m",
bg: {
red: "\x1b[41m",
green: "\x1b[42m",
yellow: "\x1b[43m",
blue: "\x1b[44m",
},
};
const border = "━".repeat(80);
// Center the title within the border
const title = "🔥 STREAMYFIN TYPESCRIPT CHECK";
const titlePadding = Math.floor((80 - title.length) / 2);
const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
console.log(String(message));
}
}
function formatError(errorLine) {
if (!useColor) return errorLine;
// Color file paths in cyan
let formatted = errorLine.replace(
/^([^(]+\([^)]+\):)/,
`${colors.cyan}$1${colors.reset}`,
);
// Color error codes in red bold
formatted = formatted.replace(
/(error TS\d+:)/g,
`${colors.red}${colors.bold}$1${colors.reset}`,
);
// Color type names in yellow
formatted = formatted.replace(
/(Type '[^']*')/g,
`${colors.yellow}$1${colors.reset}`,
);
// Color property names in magenta
formatted = formatted.replace(
/(Property '[^']*')/g,
`${colors.magenta}$1${colors.reset}`,
);
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput) {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map();
const formattedErrors = [];
let currentError = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Check if this is the start of a new error (has file path and error code)
const errorMatch = line.match(/^([^(]+\([^)]+\):)\s*(error TS\d+:)/);
if (errorMatch) {
// If we have a previous error, add it to the list
if (currentError.length > 0) {
formattedErrors.push(currentError.join("\n"));
currentError = [];
}
// Extract file info for summary
const filePath = errorMatch[1].split("(")[0];
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
// Start new error
currentError.push(formatError(line));
} else if (currentError.length > 0) {
// This is a continuation of the current error
currentError.push(` ${colors.gray}${line}${colors.reset}`);
} else if (line.match(/Found \d+ errors? in \d+ files?/)) {
// Skip the summary line; no action needed for this line
} else {
// Standalone line
formattedErrors.push(formatError(line));
}
}
// Add the last error if exists
if (currentError.length > 0) {
formattedErrors.push(currentError.join("\n"));
}
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile) {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
(a, b) => b[1] - a[1],
); // Sort by error count descending
let table = `\n${colors.gray}${colors.bold}Errors Files${colors.reset}\n`;
for (const [file, count] of sortedFiles) {
const paddedCount = String(count).padStart(6);
table += `${colors.red}${paddedCount}${colors.reset} ${colors.cyan}${file}${colors.reset}\n`;
}
return table;
}
function runTypeCheck() {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
let execArgs = null;
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch {
// fallback to PATH tsc
execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
}
try {
log(
`🔍 ${colors.bold}Running TypeScript type check...${colors.reset} ${colors.gray}${extraArgs.join(" ")}${colors.reset}`.trim(),
colors.blue,
);
const MAX_BUFFER_SIZE = 64 * 1024 * 1024; // 64MB
execFileSync(execArgs.cmd, execArgs.args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: MAX_BUFFER_SIZE,
env: { ...process.env, FORCE_COLOR: "0" },
});
log(
`${colors.bold}TypeScript check passed${colors.reset} - no errors found!`,
colors.green,
);
return { ok: true };
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors
const filteredLines = errorOutput.split("\n").filter((line) => {
const trimmedLine = line.trim();
return trimmedLine && !trimmedLine.includes("utils/jellyseerr");
});
if (filteredLines.length > 0) {
// Count TypeScript error occurrences (TS####)
const remainingMatches = (
filteredLines.join("\n").match(/\berror\s+TS\d+:/gi) || []
).length;
// Parse errors and create formatted output with summary
const { formattedErrors, errorsByFile } = parseErrorsAndCreateSummary(
filteredLines.join("\n"),
);
// Enhanced error header
log(
`\n${colors.bg.red} ERROR ${colors.reset} ${colors.red}${colors.bold}TypeScript errors found:${colors.reset}`,
);
console.log();
// Display errors with spacing between each error
for (let i = 0; i < formattedErrors.length; i++) {
console.log(formattedErrors[i]);
// Add spacing between errors (but not after the last one)
if (i < formattedErrors.length - 1) {
console.log(); // Empty line between errors
}
}
// Create and display summary table
const summaryTable = createErrorSummaryTable(errorsByFile);
if (summaryTable) {
console.log(summaryTable);
}
// Clean summary - just the error count
const errorIcon = "🚨";
log(
`${errorIcon} ${colors.red}${colors.bold}${remainingMatches} TypeScript error${remainingMatches !== 1 ? "s" : ""}${colors.reset}`,
"",
);
return { ok: false };
}
log(
`${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
colors.green,
);
return { ok: true };
}
}
// Enhanced header
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
console.log(`${colors.blue}${colors.bold}${centeredTitle}${colors.reset}`);
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
console.log();
// Main execution
const result = runTypeCheck();
console.log();
if (!result.ok) {
log(
`${colors.red}${colors.bold}🔥 Typecheck failed - please fix the errors above${colors.reset}`,
);
process.exitCode = 1;
} else {
log(
`${colors.green}${colors.bold}🎉 All checks passed! Ready to ship 🚀${colors.reset}`,
);
}

View File

@@ -1,44 +1,49 @@
{
"login": {
"username_required": "Username is required",
"username_required": "Username Is Required",
"error_title": "Error",
"login_title": "Log in",
"login_title": "Log In",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log in",
"login_button": "Log In",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got it",
"connection_failed": "Connection failed",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server",
"invalid_username_or_password": "Invalid username or password",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported jellyfin server discovered",
"too_old_server_description": "Please update jellyfin to the latest version"
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "previous servers",
"previous_servers": "Previous Servers",
"clear_button": "Clear",
"search_for_local_servers": "Search for local servers",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...",
"servers": "Servers"
},
"home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No items",
"no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
"go_to_downloads": "Go to downloads",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
@@ -48,7 +53,7 @@
"suggested_episodes": "Suggested Episodes",
"intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
@@ -58,12 +63,12 @@
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done",
"go_to_settings_button": "Go to settings",
"read_more": "Read more"
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
},
"settings": {
"settings_title": "Settings",
"log_out_button": "Log out",
"log_out_button": "Log Out",
"user_info": {
"user_info_title": "User Info",
"user": "User",
@@ -76,41 +81,41 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid code",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
},
"media_controls": {
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward skip length",
"rewind_length": "Rewind length",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal swipe to skip",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left side brightness control",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right side volume control",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item",
"audio_language": "Audio language",
"audio_language": "Audio Language",
"audio_hint": "Choose a default audio language.",
"none": "None",
"language": "Language"
},
"subtitles": {
"subtitle_title": "Subtitles",
"subtitle_language": "Subtitle language",
"subtitle_language": "Subtitle Language",
"subtitle_mode": "Subtitle Mode",
"set_subtitle_track": "Set Subtitle Track From Previous Item",
"subtitle_size": "Subtitle Size",
"subtitle_hint": "Configure subtitle preference.",
"subtitle_hint": "Configure Subtitle Preference.",
"none": "None",
"language": "Language",
"loading": "Loading",
@@ -119,13 +124,13 @@
"Smart": "Smart",
"Always": "Always",
"None": "None",
"OnlyForced": "OnlyForced"
"OnlyForced": "Only Forced"
}
},
"other": {
"other_title": "Other",
"follow_device_orientation": "Auto rotate",
"video_orientation": "Video orientation",
"follow_device_orientation": "Auto Rotate",
"video_orientation": "Video Orientation",
"orientation": "Orientation",
"orientations": {
"DEFAULT": "Default",
@@ -139,8 +144,8 @@
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe area in controls",
"video_player": "Video player",
"safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
@@ -149,24 +154,13 @@
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality",
"max_auto_play_episode_count": "Max auto play episode count",
"default_quality": "Default Quality",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"disabled": "Disabled"
},
"downloads": {
"downloads_title": "Downloads",
"download_method": "Download method",
"remux_max_download": "Remux max download",
"auto_download": "Auto download",
"optimized_versions_server": "Optimized versions server",
"save_button": "Save",
"optimized_server": "Optimized Server",
"optimized": "Optimized",
"default": "Default",
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
"read_more_about_optimized_server": "Read more about the optimize server.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
"remux_max_download": "Remux Max Download"
},
"plugins": {
"plugins_title": "Plugins",
@@ -177,17 +171,15 @@
"server_url_placeholder": "Jellyseerr URL...",
"password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}",
"save_button": "Save",
"clear_button": "Clear",
"login_button": "Login",
"total_media_requests": "Total media requests",
"movie_quota_limit": "Movie quota limit",
"movie_quota_days": "Movie quota days",
"tv_quota_limit": "TV quota limit",
"tv_quota_days": "TV quota days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Jellyseerr Config",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} more",
"plus_n_more": "+{{n}} More",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
@@ -199,7 +191,7 @@
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read more about Marlin.",
"read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save",
"toasts": {
"saved": "Saved"
@@ -210,39 +202,33 @@
"storage_title": "Storage",
"app_usage": "App {{usedSpace}}%",
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files"
},
"intro": {
"show_intro": "Show intro",
"reset_intro": "Reset intro"
"show_intro": "Show Intro",
"reset_intro": "Reset Intro"
},
"logs": {
"logs_title": "Logs",
"export_logs": "Export logs",
"click_for_more_info": "Click for more info",
"export_logs": "Export Logs",
"click_for_more_info": "Click for More Info",
"level": "Level",
"no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs"
"no_logs_available": "No Logs Available",
"delete_all_logs": "Delete All Logs"
},
"languages": {
"title": "Languages",
"app_language": "App language",
"app_language_description": "Select the language for the app.",
"app_language": "App Language",
"system": "System"
},
"toasts": {
"error_deleting_files": "Error deleting files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled",
"connected": "Connected",
"could_not_connect": "Could not connect",
"invalid_url": "Invalid URL"
"error_deleting_files": "Error Deleting Files"
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No active sessions"
"no_active_sessions": "No Active Sessions"
},
"downloads": {
"downloads_title": "Downloads",
@@ -250,60 +236,48 @@
"movies": "Movies",
"queue": "Queue",
"queue_hint": "Queue and downloads will be lost on app restart",
"no_items_in_queue": "No items in queue",
"no_downloaded_items": "No downloaded items",
"delete_all_movies_button": "Delete all Movies",
"delete_all_tvseries_button": "Delete all TV-Series",
"delete_all_button": "Delete all",
"active_download": "Active download",
"no_active_downloads": "No active downloads",
"active_downloads": "Active downloads",
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"active_download": "Active Download",
"no_active_downloads": "No Active Downloads",
"active_downloads": "Active Downloads",
"new_app_version_requires_re_download": "New app version requires re-download",
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"something_went_wrong": "Something went wrong",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Methods",
"toasts": {
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted all movies successfully!",
"failed_to_delete_all_movies": "Failed to delete all movies",
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
"download_deleted": "Download deleted",
"could_not_delete_download": "Could not delete download",
"download_paused": "Download paused",
"could_not_pause_download": "Could not pause download",
"download_resumed": "Download resumed",
"could_not_resume_download": "Could not resume download",
"download_completed": "Download completed",
"download_started_for": "Download started for {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
"download_stated_for_item": "Download started for {{item}}",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"download_deleted": "Download Deleted",
"could_not_delete_download": "Could Not Delete Download",
"download_paused": "Download Paused",
"could_not_pause_download": "Could Not Pause Download",
"download_resumed": "Download Resumed",
"could_not_resume_download": "Could Not Resume Download",
"download_completed": "Download Completed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download completed for {{item}}",
"queued_item_for_optimization": "Queued {{item}} for optimization",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
"download_completed_for_item": "Download Completed for {{item}}",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
"go_to_downloads": "Go to downloads"
"go_to_downloads": "Go to Downloads"
}
}
},
"search": {
"search_here": "Search here...",
"search": "Search...",
"x_items": "{{count}} items",
"x_items": "{{count}} Items",
"library": "Library",
"discover": "Discover",
"no_results": "No results",
"no_results_found_for": "No results found for",
"no_results": "No Results",
"no_results_found_for": "No Results Found For",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
@@ -334,32 +308,29 @@
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
},
"library": {
"no_items_found": "No items found",
"no_results": "No results",
"no_libraries_found": "No libraries found",
"no_results": "No Results",
"no_libraries_found": "No Libraries Found",
"item_types": {
"movies": "movies",
"series": "series",
"boxsets": "box sets",
"items": "items"
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"items": "Items"
},
"options": {
"display": "Display",
"row": "Row",
"list": "List",
"image_style": "Image style",
"image_style": "Image Style",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show titles",
"show_stats": "Show stats"
"show_titles": "Show Titles",
"show_stats": "Show Stats"
},
"filters": {
"genres": "Genres",
"years": "Years",
"sort_by": "Sort By",
"sort_order": "Sort Order",
"asc": "Ascending",
"desc": "Descending",
"tags": "Tags"
}
},
@@ -370,34 +341,30 @@
"videos": "Videos",
"boxsets": "Boxsets",
"playlists": "Playlists",
"noDataTitle": "No favorites yet",
"noDataTitle": "No Favorites Yet",
"noData": "Mark items as favorites to see them appear here for quick access."
},
"custom_links": {
"no_links": "No links"
"no_links": "No Links"
},
"player": {
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}",
"video_has_finished_playing": "Video has finished playing!",
"no_video_source": "No video source...",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"subtitle_tracks": "Subtitle Tracks:",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"no_data_available": "No data available",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go back"
"go_back": "Go Back"
},
"item_card": {
"next_up": "Next up",
"no_items_to_display": "No items to display",
"next_up": "Next Up",
"no_items_to_display": "No Items to Display",
"cast_and_crew": "Cast & Crew",
"series": "Series",
"seasons": "Seasons",
@@ -405,36 +372,33 @@
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
"similar_items": "Similar items",
"no_similar_items_found": "No similar items found",
"similar_items": "Similar Items",
"no_similar_items_found": "No Similar Items Found",
"video": "Video",
"more_details": "More details",
"more_details": "More Details",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"show_more": "Show more",
"show_less": "Show less",
"appeared_in": "Appeared in",
"could_not_load_item": "Could not load item",
"show_more": "Show More",
"show_less": "Show Less",
"appeared_in": "Appeared In",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
"download_season": "Download Season",
"download_series": "Download Series",
"download_episode": "Download Episode",
"download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} items",
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download",
"using_optimized_server": "Using optimized server",
"using_default_method": "Using default method"
"download_button": "Download"
}
},
"live_tv": {
"next": "Next",
"previous": "Previous",
"live_tv": "Live TV",
"coming_soon": "Coming soon",
"on_now": "On now",
"coming_soon": "Coming Soon",
"on_now": "On Now",
"shows": "Shows",
"movies": "Movies",
"sports": "Sports",
@@ -445,16 +409,16 @@
"confirm": "Confirm",
"cancel": "Cancel",
"yes": "Yes",
"whats_wrong": "What's wrong?",
"issue_type": "Issue type",
"select_an_issue": "Select an issue",
"whats_wrong": "What's Wrong?",
"issue_type": "Issue Type",
"select_an_issue": "Select an Issue",
"types": "Types",
"describe_the_issue": "(optional) Describe the issue...",
"describe_the_issue": "(Optional) Describe the Issue...",
"submit_button": "Submit",
"report_issue_button": "Report issue",
"report_issue_button": "Report Issue",
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to login",
"failed_to_login": "Failed to Login",
"cast": "Cast",
"details": "Details",
"status": "Status",
@@ -469,13 +433,13 @@
"production_country": "Production Country",
"studios": "Studios",
"network": "Network",
"currently_streaming_on": "Currently Streaming on",
"currently_streaming_on": "Currently Streaming On",
"advanced": "Advanced",
"request_as": "Request As",
"tags": "Tags",
"quality_profile": "Quality Profile",
"root_folder": "Root Folder",
"season_all": "Season (all)",
"season_all": "Season (All)",
"season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",
@@ -484,7 +448,7 @@
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
"issue_submitted": "Issue submitted!",
"issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!"

View File

@@ -9,6 +9,7 @@ import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -18,7 +19,7 @@ import type { MovieDetails } from "../jellyseerr/server/models/Movie";
import type { TvDetails } from "../jellyseerr/server/models/Tv";
export const useJellyseerrCanRequest = (
item?: MovieResult | TvResult | MovieDetails | TvDetails,
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast,
) => {
const { jellyseerrUser } = useJellyseerr();

View File

@@ -56,7 +56,7 @@ export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null);
const { settings } = useSettings();
useEffect(() => {
if (

View File

@@ -1,4 +1,3 @@
import type { Api } from "@jellyfin/sdk";
import {
type BaseItemKind,
type CultureDto,
@@ -7,11 +6,12 @@ import {
type SortOrder,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
import { atom, useAtom } from "jotai";
import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
import { storage } from "../mmkv";
@@ -88,6 +88,7 @@ export type Home = {
};
export type HomeSection = {
title?: string;
orientation?: "horizontal" | "vertical";
items?: HomeSectionItemResolver;
nextUp?: HomeSectionNextUpResolver;
@@ -278,7 +279,8 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(),
);
export const useSettings = (api: Api | null) => {
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
@@ -302,11 +304,11 @@ export const useSettings = (api: Api | null) => {
return;
}
const settings = await api.getStreamyfinPluginConfig().then(
({ data }: any) => {
({ data }) => {
writeInfoLog("Got plugin settings", data?.settings);
return data?.settings;
},
(_err: any) => undefined,
(_err) => undefined,
);
setPluginSettings(settings);
return settings;
@@ -367,11 +369,11 @@ export const useSettings = (api: Api | null) => {
};
}, [_settings, pluginSettings]);
return [
return {
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] as const;
};
};