Compare commits

...

16 Commits

Author SHA1 Message Date
Gauvain
8eeb571f33 refactor: remove dead code
Delete unused components, hooks and utilities that have no remaining references, and drop their now-unused exports (augmentations/string, Number.prototype.hoursToMilliseconds). Also drop the dangling ExampleGlobalModalUsage reference from GLOBAL_MODAL_GUIDE.md.
2026-06-01 00:54:18 +02:00
Felix Schneider
6b7ee0514f feat(i18n): add new translations for action sheet options (#1475)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🌐 Translation Sync / sync-translations (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 23:45:45 +02:00
Fredrik Burmester
c663bd0413 fix(jellyseerr): correct RequestModal ref type to fix typecheck
advancedReqModalRef was typed as BottomSheetModal but RequestModal's
forwardRef expects BottomSheetModalMethods, causing a TS2322 error
that broke the Security & Quality Gate typecheck on develop.
2026-05-31 22:10:15 +02:00
Alex
52e6f56220 fix(auth): clear stored user on logout to prevent empty home on relaunch (#1622) 2026-05-31 21:52:41 +02:00
Fredrik Burmester
c981f59a50 fix(downloads): repair bottom sheets on iOS and restore downloads delete sheet
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Bump @gorhom/bottom-sheet 5.2.8 -> 5.2.14, which fixes BottomSheetModal
present() silently no-opping under Reanimated 4 / New Architecture (SDK 56).
Affected every sheet app-wide: present() was called with a valid ref but
nothing rendered (not even the backdrop). Verified on the iOS simulator that
the download options sheet now opens.

Also restore the downloads-page delete sheet (delete movies/series/other/all)
that was accidentally dropped in the Expo 54 rewrite (#1174), which left an
orphaned trigger button and underscore-silenced handlers.
2026-05-31 14:58:51 +02:00
Alex
62fc6f9a70 fix(progress-bar): Fix progress bar not reporting watch times (#1611)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 22:12:13 +10:00
Fredrik Burmester
eb8dd51b4e feat(ci): EAS build + auto-submit release workflow for main (#1616) 2026-05-31 13:23:02 +02:00
Fredrik Burmester
ea5a999f21 fix(deps): declare react-native-tab-view and material-top-tabs (#1617) 2026-05-31 12:40:56 +02:00
Felix Schneider
dffcdef945 feat(i18n): Add translation for "ends at" (#1474)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 12:10:22 +02:00
Fredrik Burmester
fa1c3f3947 chore(eas): pin appleTeamId and ascAppId in submit profiles
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Avoid the interactive Apple team picker and app-existence lookup on
submit by pinning the Individual team (MWD5K362T8) and ASC App ID.
2026-05-31 11:22:59 +02:00
Fredrik Burmester
2761de5a74 chore(eas): use remote app version source with autoIncrement
Switch cli.appVersionSource to remote and enable autoIncrement on all
production profiles so EAS bumps the build number every release instead
of resetting to 1. Remove the dead android.versionCode from app.json and
the unused EAS Update channel (no expo-updates installed).
2026-05-31 11:22:59 +02:00
Fredrik Burmester
feca1d7e9c fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) 2026-05-31 10:50:06 +02:00
Fredrik Burmester
6b6bfd1a89 fix(player): remove white blob artifacts on vertical sliders 2026-05-31 10:48:24 +02:00
Fredrik Burmester
d585b20f49 chore: version 2026-05-31 09:44:05 +02:00
Fredrik Burmester
692ccfdb2c fix(tvos): add arm64 UIRequiredDeviceCapabilities to Top Shelf extension
App Store Connect rejected TestFlight submissions because the Top Shelf
extension binary has a 64-bit slice but did not declare arm64 under
UIRequiredDeviceCapabilities in its Info.plist.
2026-05-31 09:42:09 +02:00
Fredrik Burmester
86e39c444c fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) 2026-05-31 09:37:08 +02:00
47 changed files with 341 additions and 1004 deletions

132
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
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
View File

@@ -18,6 +18,9 @@ web-build/
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
@@ -76,3 +79,6 @@ build/
.claude/
.agents/skills/**
skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

View File

@@ -143,14 +143,6 @@ 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
The modal uses these default styles (can be overridden via options):

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.54.0",
"version": "0.54.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,6 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
"versionCode": 93,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -1,4 +1,9 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={bottomSheetModalRef.current?.present}
onPress={() => bottomSheetModalRef.current?.present()}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -127,7 +133,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const _deleteShows = () =>
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -138,7 +144,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const _deleteOtherMedia = () =>
const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<OfflineModeProvider isOffline={true}>
<ScrollView
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{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>
);
}

View File

@@ -6,6 +6,7 @@ import {
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {

View File

@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

View File

@@ -1,14 +0,0 @@
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 {};

View File

@@ -11,9 +11,10 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -83,6 +84,7 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
@@ -114,11 +116,6 @@
},
},
},
"patchedDependencies": {
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -368,7 +365,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=="],
"@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/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/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -542,6 +539,10 @@
"@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/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
@@ -1594,6 +1595,8 @@
"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-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"],
@@ -2006,6 +2009,10 @@
"@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/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -2224,6 +2231,14 @@
"@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=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2336,6 +2351,14 @@
"@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=="],
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],

View File

@@ -1,203 +0,0 @@
/**
* 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>
);
};

View File

@@ -1,20 +0,0 @@
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>
);
};

View File

@@ -37,11 +37,12 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
}
/>
<View
style={{
width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}}
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
style={
Platform.isTV
? { width: `${progress}%`, backgroundColor: "#ffffff" }
: { width: `${progress}%` }
}
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
/>
</>
);

View File

@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
TouchableOpacity,
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
const { t } = useTranslation();
const segments = useSegments();
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return;
const options: string[] = [
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
...(isOffline ? ["Delete Download"] : []),
"Cancel",
t("common.mark_as_played"),
t("common.mark_as_not_played"),
isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isOffline,
deleteFile,
item.Id,
t,
]);
if (

View File

@@ -1,28 +0,0 @@
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>
);
};

View File

@@ -1,12 +0,0 @@
// 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} />;
}

View File

@@ -1,63 +0,0 @@
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>
);
};

View File

@@ -1,48 +0,0 @@
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;

View File

@@ -1,29 +0,0 @@
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>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time";
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime,
remainingTime,
}) => {
const { t } = useTranslation();
const getFinishTime = () => {
const now = new Date();
// remainingTime is in ms
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
ends at {getFinishTime()}
{t("player.ends_at", { time: getFinishTime() })}
</Text>
</View>
</View>

View File

@@ -1,39 +0,0 @@
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" },
];

View File

@@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 9.1.0"
"version": ">= 16.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
@@ -51,23 +52,26 @@
}
},
"production": {
"bun": "1.3.5",
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"image": "latest"
}
},
"production-apk": {
"bun": "1.3.5",
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"bun": "1.3.5",
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"android": {
"buildType": "apk",
"image": "latest"
@@ -77,8 +81,9 @@
}
},
"production_tv": {
"bun": "1.3.5",
"environment": "production",
"channel": "0.54.0",
"autoIncrement": true,
"env": {
"EXPO_TV": "1"
},
@@ -88,7 +93,22 @@
}
},
"submit": {
"production": {},
"production_tv": {}
"production": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
}
}

View File

@@ -1,37 +0,0 @@
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 };
};

View File

@@ -1,35 +0,0 @@
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 };
};

View File

@@ -1,120 +0,0 @@
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;
};

View File

@@ -715,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// dropped), so we (re)apply here for embedded and external alike.
// This is what makes a carried-over subtitle show up on the next
// episode without a manual re-selection.
if (initialAudioId != null && initialAudioId > 0) {
setAudioTrack(initialAudioId)
}
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
if (!isReadyToSeek) {

View File

@@ -32,9 +32,10 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",
"@react-navigation/native": "^7.2.5",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -104,6 +105,7 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
@@ -162,10 +164,5 @@
},
"trustedDependencies": [
"unrs-resolver"
],
"patchedDependencies": {
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
}
]
}

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
}
#endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown

View File

@@ -1,7 +1,7 @@
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType);
};

View File

@@ -1,10 +1,7 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
--- a/node_modules/react-native-udp/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]

View File

@@ -39,6 +39,28 @@ function buildPatch() {
" end",
" end",
"",
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
" # files, so the pods-only flag above is not enough — the app's link still fails with",
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
" if ENV['EXPO_TV'] != '1'",
" installer.aggregate_targets.each do |agg|",
" next unless agg.user_project",
" agg.user_project.native_targets.each do |target|",
" target.build_configurations.each do |cfg|",
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
" existing = existing.join(' ') if existing.is_a?(Array)",
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
" end",
" end",
" end",
" agg.user_project.save",
" end",
" end",
"",
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
" if File.exist?(filepath)",

View File

@@ -53,7 +53,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => {
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");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -128,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync();
return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.0" },
clientInfo: { name: "Streamyfin", version: "0.54.1" },
deviceInfo: {
name: deviceName,
id,
@@ -162,7 +169,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.0"`,
}, DeviceId="${deviceId}", Version="0.54.1"`,
};
}, [deviceId]);
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);

View File

@@ -29,6 +29,10 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -456,6 +456,7 @@
"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",
"delete": "Löschen",
"delete_download": "Download löschen",
"something_went_wrong": "Etwas ist schiefgelaufen",
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
"eta": "ETA {{eta}}",
@@ -498,6 +499,8 @@
"audio": "Audio",
"subtitle": "Untertitel",
"play": "Abspielen",
"mark_as_played": "Als gesehen markieren",
"mark_as_not_played": "Als ungesehen markieren",
"none": "Keine",
"track": "Spur",
"cancel": "Abbrechen",
@@ -608,7 +611,8 @@
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen"
"downloaded_file_cancel": "Abbrechen",
"ends_at": "Endet um {{time}}"
},
"item_card": {
"next_up": "Als Nächstes",

View File

@@ -534,6 +534,7 @@
"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",
"delete_download": "Delete Download",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
@@ -577,6 +578,8 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"mark_as_played": "Mark as Played",
"mark_as_not_played": "Mark as not Played",
"none": "None",
"track": "Track",
"cancel": "Cancel",
@@ -698,7 +701,7 @@
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "ends at",
"ends_at": "Ends at {{time}}",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",

View File

@@ -1,18 +0,0 @@
/**
* 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`;
}

View File

@@ -1,47 +0,0 @@
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;
};

View File

@@ -1,56 +0,0 @@
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;
}

View File

@@ -1,56 +0,0 @@
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");
}
};

View File

@@ -1,44 +0,0 @@
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 [];
}
};

View File

@@ -1,34 +0,0 @@
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}`);
}
};

View File

@@ -1,5 +0,0 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}