mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
Compare commits
1 Commits
cleanup/de
...
renovate/x
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b8478512e |
8
.github/workflows/build-apps.yml
vendored
8
.github/workflows/build-apps.yml
vendored
@@ -219,7 +219,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -284,7 +284,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -344,7 +344,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -412,7 +412,7 @@ jobs:
|
|||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||||
xcode-version: "26.4"
|
xcode-version: "26.5"
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
132
.github/workflows/release.yml
vendored
132
.github/workflows/release.yml
vendored
@@ -1,132 +0,0 @@
|
|||||||
name: 🚀 Release (EAS Build + Submit)
|
|
||||||
|
|
||||||
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
|
|
||||||
# A manual approval gate (the `production` GitHub Environment) pauses the run
|
|
||||||
# before any build/submit starts. Configure required reviewers on that
|
|
||||||
# environment in repo Settings → Environments → production.
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approve:
|
|
||||||
name: 🔐 Approve release
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- name: ✅ Release approved
|
|
||||||
run: echo "Release approved for ${{ github.sha }}"
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: 🚀 ${{ matrix.name }}
|
|
||||||
needs: approve
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: 🍎 iOS
|
|
||||||
platform: ios
|
|
||||||
profile: production
|
|
||||||
- name: 📺 tvOS
|
|
||||||
platform: ios
|
|
||||||
profile: production_tv
|
|
||||||
- name: 🤖 Android
|
|
||||||
platform: android
|
|
||||||
profile: production
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
|
||||||
with:
|
|
||||||
eas-version: latest
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
eas-cache: true
|
|
||||||
|
|
||||||
# tvOS uses local credentials (EAS can't manage tvOS provisioning
|
|
||||||
# remotely, including the TopShelf extension target). Restore the
|
|
||||||
# gitignored credentials.json + cert + profiles from secrets so the
|
|
||||||
# cloud build can sign with `credentialsSource: local`.
|
|
||||||
- name: 🔐 Restore tvOS signing credentials
|
|
||||||
if: matrix.profile == 'production_tv'
|
|
||||||
env:
|
|
||||||
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
|
|
||||||
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
|
|
||||||
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
|
|
||||||
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
mkdir -p certs profiles
|
|
||||||
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
|
|
||||||
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
|
|
||||||
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
|
|
||||||
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
|
|
||||||
|
|
||||||
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
|
|
||||||
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
|
|
||||||
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
|
|
||||||
# tolerating either raw-PEM or base64-encoded secret content.
|
|
||||||
- name: 🔐 Restore App Store Connect API key
|
|
||||||
if: matrix.platform == 'ios'
|
|
||||||
env:
|
|
||||||
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
|
|
||||||
run: |
|
|
||||||
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
else
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Android submit needs a Google Play service account JSON. eas.json's
|
|
||||||
# submit.production.android.serviceAccountKeyPath points at this file.
|
|
||||||
- name: 🔐 Restore Google Play service account
|
|
||||||
if: matrix.platform == 'android'
|
|
||||||
env:
|
|
||||||
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
|
||||||
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
|
|
||||||
|
|
||||||
- name: 🚀 Build & submit (${{ matrix.name }})
|
|
||||||
env:
|
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
# Consumed by eas submit for iOS/tvOS; ignored for Android.
|
|
||||||
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
|
|
||||||
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
||||||
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
|
||||||
run: |
|
|
||||||
eas build \
|
|
||||||
--platform ${{ matrix.platform }} \
|
|
||||||
--profile ${{ matrix.profile }} \
|
|
||||||
--auto-submit \
|
|
||||||
--non-interactive
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,9 +18,6 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
|
||||||
**/.gradle/
|
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/mpv-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
@@ -79,6 +76,3 @@ build/
|
|||||||
.claude/
|
.claude/
|
||||||
.agents/skills/**
|
.agents/skills/**
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
# CI-injected Google Play service account key (written at build time)
|
|
||||||
google-service-account.json
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -12,7 +7,6 @@ import { Alert, Platform, ScrollView, View } from "react-native";
|
|||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -107,7 +101,7 @@ export default function DownloadsPage() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => bottomSheetModalRef.current?.present()}
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
@@ -122,7 +116,7 @@ export default function DownloadsPage() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -133,7 +127,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -144,7 +138,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia
|
otherMedia
|
||||||
.filter((item) => item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
@@ -168,9 +162,6 @@ export default function DownloadsPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={true}>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -265,42 +256,6 @@ export default function DownloadsPage() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
|
||||||
</Button>
|
|
||||||
<Button color='purple' onPress={deleteShows}>
|
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
|
||||||
</Button>
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
|
||||||
{t("home.downloads.delete_all_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
@@ -77,7 +76,7 @@ const MobilePage: React.FC = () => {
|
|||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this.replaceAll("_", " ").replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
32
bun.lock
32
bun.lock
@@ -11,10 +11,9 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.14",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -84,7 +83,6 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-tab-view": "4.3.0",
|
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -365,7 +363,7 @@
|
|||||||
|
|
||||||
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
||||||
|
|
||||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
|
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
||||||
|
|
||||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||||
|
|
||||||
@@ -539,10 +537,6 @@
|
|||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
||||||
|
|
||||||
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
|
|
||||||
|
|
||||||
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
||||||
|
|
||||||
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
||||||
@@ -1595,8 +1589,6 @@
|
|||||||
|
|
||||||
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
||||||
|
|
||||||
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
|
|
||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
@@ -2009,10 +2001,6 @@
|
|||||||
|
|
||||||
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||||
|
|
||||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
@@ -2231,14 +2219,6 @@
|
|||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
|
||||||
|
|
||||||
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
@@ -2351,14 +2331,6 @@
|
|||||||
|
|
||||||
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -37,12 +37,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={
|
style={{
|
||||||
Platform.isTV
|
width: `${progress}%`,
|
||||||
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
||||||
: { width: `${progress}%` }
|
}}
|
||||||
}
|
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -150,7 +149,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
@@ -184,13 +182,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
const options: string[] = [
|
||||||
t("common.mark_as_played"),
|
"Mark as Played",
|
||||||
t("common.mark_as_not_played"),
|
"Mark as Not Played",
|
||||||
isFavorite
|
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||||
? t("music.track_options.remove_from_favorites")
|
...(isOffline ? ["Delete Download"] : []),
|
||||||
: t("music.track_options.add_to_favorites"),
|
"Cancel",
|
||||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
|
||||||
t("common.cancel"),
|
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = isOffline
|
||||||
@@ -223,7 +219,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
@@ -17,8 +16,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
@@ -40,7 +37,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
ends at {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||||
|
{ label: "English", value: "eng" },
|
||||||
|
{ label: "Spanish", value: "spa" },
|
||||||
|
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||||
|
{ label: "Hindi", value: "hin" },
|
||||||
|
{ label: "Arabic", value: "ara" },
|
||||||
|
{ label: "French", value: "fra" },
|
||||||
|
{ label: "Russian", value: "rus" },
|
||||||
|
{ label: "Portuguese", value: "por" },
|
||||||
|
{ label: "Japanese", value: "jpn" },
|
||||||
|
{ label: "German", value: "deu" },
|
||||||
|
{ label: "Italian", value: "ita" },
|
||||||
|
{ label: "Korean", value: "kor" },
|
||||||
|
{ label: "Turkish", value: "tur" },
|
||||||
|
{ label: "Dutch", value: "nld" },
|
||||||
|
{ label: "Polish", value: "pol" },
|
||||||
|
{ label: "Vietnamese", value: "vie" },
|
||||||
|
{ label: "Thai", value: "tha" },
|
||||||
|
{ label: "Indonesian", value: "ind" },
|
||||||
|
{ label: "Greek", value: "ell" },
|
||||||
|
{ label: "Swedish", value: "swe" },
|
||||||
|
{ label: "Danish", value: "dan" },
|
||||||
|
{ label: "Norwegian", value: "nor" },
|
||||||
|
{ label: "Finnish", value: "fin" },
|
||||||
|
{ label: "Czech", value: "ces" },
|
||||||
|
{ label: "Hungarian", value: "hun" },
|
||||||
|
{ label: "Romanian", value: "ron" },
|
||||||
|
{ label: "Ukrainian", value: "ukr" },
|
||||||
|
{ label: "Hebrew", value: "heb" },
|
||||||
|
{ label: "Bengali", value: "ben" },
|
||||||
|
{ label: "Punjabi", value: "pan" },
|
||||||
|
{ label: "Tagalog", value: "tgl" },
|
||||||
|
{ label: "Swahili", value: "swa" },
|
||||||
|
{ label: "Malay", value: "msa" },
|
||||||
|
{ label: "Persian", value: "fas" },
|
||||||
|
{ label: "Urdu", value: "urd" },
|
||||||
|
];
|
||||||
11
eas.json
11
eas.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 16.0.0",
|
"version": ">= 9.1.0",
|
||||||
"appVersionSource": "remote"
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -60,7 +59,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -69,7 +67,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
@@ -81,7 +78,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
@@ -97,11 +93,6 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"appleTeamId": "MWD5K362T8",
|
"appleTeamId": "MWD5K362T8",
|
||||||
"ascAppId": "6593660679"
|
"ascAppId": "6593660679"
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"serviceAccountKeyPath": "./google-service-account.json",
|
|
||||||
"track": "internal",
|
|
||||||
"releaseStatus": "completed"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
|
|||||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export const useDownloadedFileOpener = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!item.Id) {
|
||||||
|
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||||
|
console.error("Attempted to open a file without an ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOfflineSettings, setPlayUrl, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { openFile };
|
||||||
|
};
|
||||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
|
import {
|
||||||
|
adjustToNearBlack,
|
||||||
|
calculateTextColor,
|
||||||
|
isCloseToBlack,
|
||||||
|
itemThemeColorAtom,
|
||||||
|
} from "@/utils/atoms/primaryColor";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
|
*
|
||||||
|
* @param item - The BaseItemDto object representing the item.
|
||||||
|
* @param disabled - A boolean flag to disable color extraction.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useImageColors = ({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
url?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api) return;
|
||||||
|
if (url) return { uri: url };
|
||||||
|
if (item)
|
||||||
|
return getItemImage({
|
||||||
|
item,
|
||||||
|
api,
|
||||||
|
variant: "Primary",
|
||||||
|
quality: 80,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [api, item, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTv) return;
|
||||||
|
if (disabled) return;
|
||||||
|
if (source?.uri) {
|
||||||
|
const _primary = storage.getString(`${source.uri}-primary`);
|
||||||
|
const _text = storage.getString(`${source.uri}-text`);
|
||||||
|
|
||||||
|
if (_primary && _text) {
|
||||||
|
setPrimaryColor({
|
||||||
|
primary: _primary,
|
||||||
|
text: _text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from the image
|
||||||
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: false,
|
||||||
|
})
|
||||||
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
|
let primary = "#fff";
|
||||||
|
let text = "#000";
|
||||||
|
let backup = "#fff";
|
||||||
|
|
||||||
|
// Select the appropriate color based on the platform
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
primary = colors.dominant;
|
||||||
|
backup = colors.vibrant;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
primary = colors.detail;
|
||||||
|
backup = colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust the primary color if it's too close to black
|
||||||
|
if (primary && isCloseToBlack(primary)) {
|
||||||
|
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||||
|
primary = adjustToNearBlack(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the text color based on the primary color
|
||||||
|
if (primary) text = calculateTextColor(primary);
|
||||||
|
|
||||||
|
setPrimaryColor({
|
||||||
|
primary,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the colors in storage
|
||||||
|
if (source.uri && primary) {
|
||||||
|
storage.set(`${source.uri}-primary`, primary);
|
||||||
|
storage.set(`${source.uri}-text`, text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||||
|
|
||||||
|
if (isTv) return;
|
||||||
|
};
|
||||||
@@ -32,10 +32,9 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.14",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -105,7 +104,6 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-tab-view": "4.3.0",
|
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
|
|||||||
@@ -69,13 +69,6 @@ const initialApi = (() => {
|
|||||||
|
|
||||||
const initialUser = (() => {
|
const initialUser = (() => {
|
||||||
try {
|
try {
|
||||||
// Only return a stored user if we also have a token. Otherwise the
|
|
||||||
// user atom would be populated while the api atom is null (e.g. after
|
|
||||||
// a logout that left stale user JSON in storage), which causes
|
|
||||||
// useProtectedRoute to keep us inside the (auth) group instead of
|
|
||||||
// redirecting to /login.
|
|
||||||
const token = storage.getString("token");
|
|
||||||
if (!token) return null;
|
|
||||||
const userStr = storage.getString("user");
|
const userStr = storage.getString("user");
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
return JSON.parse(userStr) as UserDto;
|
return JSON.parse(userStr) as UserDto;
|
||||||
@@ -409,7 +402,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
storage.remove("user");
|
|
||||||
clearTVDiscoverySafely();
|
clearTVDiscoverySafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
|||||||
@@ -456,7 +456,6 @@
|
|||||||
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"delete_download": "Download löschen",
|
|
||||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -499,8 +498,6 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Untertitel",
|
"subtitle": "Untertitel",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
"mark_as_played": "Als gesehen markieren",
|
|
||||||
"mark_as_not_played": "Als ungesehen markieren",
|
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"track": "Spur",
|
"track": "Spur",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -611,8 +608,7 @@
|
|||||||
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen",
|
"downloaded_file_cancel": "Abbrechen"
|
||||||
"ends_at": "Endet um {{time}}"
|
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
|
|||||||
@@ -534,7 +534,6 @@
|
|||||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
"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",
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_download": "Delete Download",
|
|
||||||
"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",
|
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -578,8 +577,6 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"mark_as_played": "Mark as Played",
|
|
||||||
"mark_as_not_played": "Mark as not Played",
|
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -701,7 +698,7 @@
|
|||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel",
|
"downloaded_file_cancel": "Cancel",
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "Ends at {{time}}",
|
"ends_at": "ends at",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracks",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
|
|||||||
18
utils/bToMb.ts
Normal file
18
utils/bToMb.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Convert bits to megabits or gigabits
|
||||||
|
*
|
||||||
|
* Return nice looking string
|
||||||
|
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||||
|
if (!bits) return "0MB";
|
||||||
|
|
||||||
|
const megabits = bits / 1000000;
|
||||||
|
|
||||||
|
if (megabits < 1000) {
|
||||||
|
return `${Math.round(megabits)}MB`;
|
||||||
|
}
|
||||||
|
const gigabits = megabits / 1000;
|
||||||
|
return `${gigabits.toFixed(1)}GB`;
|
||||||
|
}
|
||||||
47
utils/collectionTypeToItemType.ts
Normal file
47
utils/collectionTypeToItemType.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||||
|
*
|
||||||
|
* CollectionTypes
|
||||||
|
* readonly Unknown: "unknown";
|
||||||
|
readonly Movies: "movies";
|
||||||
|
readonly Tvshows: "tvshows";
|
||||||
|
readonly Trailers: "trailers";
|
||||||
|
readonly Homevideos: "homevideos";
|
||||||
|
readonly Boxsets: "boxsets";
|
||||||
|
readonly Books: "books";
|
||||||
|
readonly Photos: "photos";
|
||||||
|
readonly Livetv: "livetv";
|
||||||
|
readonly Playlists: "playlists";
|
||||||
|
readonly Folders: "folders";
|
||||||
|
*/
|
||||||
|
export const colletionTypeToItemType = (
|
||||||
|
collectionType?: CollectionType | null,
|
||||||
|
): BaseItemKind | undefined => {
|
||||||
|
if (!collectionType) return undefined;
|
||||||
|
|
||||||
|
switch (collectionType) {
|
||||||
|
case CollectionType.Movies:
|
||||||
|
return BaseItemKind.Movie;
|
||||||
|
case CollectionType.Tvshows:
|
||||||
|
return BaseItemKind.Series;
|
||||||
|
case CollectionType.Homevideos:
|
||||||
|
return BaseItemKind.Video;
|
||||||
|
case CollectionType.Books:
|
||||||
|
return BaseItemKind.Book;
|
||||||
|
case CollectionType.Playlists:
|
||||||
|
return BaseItemKind.Playlist;
|
||||||
|
case CollectionType.Folders:
|
||||||
|
return BaseItemKind.Folder;
|
||||||
|
case CollectionType.Photos:
|
||||||
|
return BaseItemKind.Photo;
|
||||||
|
case CollectionType.Trailers:
|
||||||
|
return BaseItemKind.Trailer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
language: string;
|
||||||
|
default: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
autoSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseM3U8ForSubtitles(
|
||||||
|
url: string,
|
||||||
|
): Promise<SubtitleTrack[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: "text" });
|
||||||
|
const lines = response.data.split(/\r?\n/);
|
||||||
|
const subtitleTracks: SubtitleTrack[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||||
|
const attributes = parseAttributes(line);
|
||||||
|
const track: SubtitleTrack = {
|
||||||
|
index: index++,
|
||||||
|
name: attributes.NAME || "",
|
||||||
|
uri: attributes.URI || "",
|
||||||
|
language: attributes.LANGUAGE || "",
|
||||||
|
default: attributes.DEFAULT === "YES",
|
||||||
|
forced: attributes.FORCED === "YES",
|
||||||
|
autoSelect: attributes.AUTOSELECT === "YES",
|
||||||
|
};
|
||||||
|
subtitleTracks.push(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subtitleTracks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributes(line: string): { [key: string]: string } {
|
||||||
|
const attributes: { [key: string]: string } = {};
|
||||||
|
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
|
||||||
|
for (const match of line.matchAll(regex)) {
|
||||||
|
const key = match[1];
|
||||||
|
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||||
|
attributes[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
56
utils/jellyfin/session/capabilities.ts
Normal file
56
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Settings } from "../../atoms/settings";
|
||||||
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
deviceProfile: Settings["deviceProfile"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = api.axiosInstance.post(
|
||||||
|
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
|
supportedCommands: [
|
||||||
|
"PlayState",
|
||||||
|
"Play",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayMessage",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
DeviceProfile: generateDeviceProfile(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return d;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface NextUpParams {
|
||||||
|
itemId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
api?: Api | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next up episodes for a series or all series for a user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for fetching next up episodes
|
||||||
|
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||||
|
*/
|
||||||
|
export const nextUp = async ({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
api,
|
||||||
|
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||||
|
if (!userId || !api) {
|
||||||
|
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||||
|
`${api.basePath}/Shows/NextUp`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
SeriesId: itemId || undefined,
|
||||||
|
UserId: userId,
|
||||||
|
Fields: "MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.Items;
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
34
utils/jellyfin/user-library/getItemById.ts
Normal file
34
utils/jellyfin/user-library/getItemById.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an item by its ID from the API.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param itemId - The ID of the item to retrieve.
|
||||||
|
* @returns The item object or undefined if no item matches the ID.
|
||||||
|
*/
|
||||||
|
export const getItemById = async (
|
||||||
|
api?: Api | null | undefined,
|
||||||
|
itemId?: string | null | undefined,
|
||||||
|
): Promise<BaseItemDto | undefined> => {
|
||||||
|
if (!api || !itemId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||||
|
|
||||||
|
const item = itemData.data;
|
||||||
|
if (!item) {
|
||||||
|
console.error("No items found with the specified ID:", itemId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve the item:", error);
|
||||||
|
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
5
utils/secondsToTicks.ts
Normal file
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user