Compare commits

..

17 Commits

Author SHA1 Message Date
Uruk
a41802f30e fix: Improves video player reliability and performance
Prevents crashes by adding safeguards that check if video is loaded before calling player methods

Removes performance monitoring hook that was causing unnecessary overhead during playback

Reorganizes code structure by removing excessive comment sections and consolidating related functionality for better maintainability

Updates Biome linter to latest version for improved code formatting and analysis
2025-08-11 01:03:06 +02:00
Uruk
6686da2bea refactor: Refactors video player with state consolidation and performance optimizations
Consolidates scattered video state into a unified reducer pattern for better state management and predictability.

Adds performance monitoring hooks to identify rendering bottlenecks and implements memory cleanup mechanisms including periodic cache clearing and proper component disposal.

Optimizes VLC initialization with reduced network caching settings and improved track pre-selection logic.

Wraps unsafe player method calls with error-safe helpers to prevent crashes from missing or failed operations.

Improves data fetching with proper abort controller cleanup and consolidated playback state reporting.
2025-08-10 23:48:32 +02:00
renovate[bot]
9597b40726 chore(deps): update github/codeql-action action to v3.29.8 (#917)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-10 22:51:29 +02:00
Gauvain
1e6408d5be chore: resolve final biome warning with explicit type annotations (#908) 2025-08-08 14:38:00 +02:00
Gauvain
c2f6897f47 fix(ci): Disables fail-fast for CI build matrices (#910) 2025-08-08 14:37:43 +02:00
Jaakko Rantamäki
eaf3682384 fix: Android adaptive and themed icons (#762) 2025-08-08 10:30:00 +02:00
renovate[bot]
f3c7b636a8 chore(deps): update ci dependencies (#911) 2025-08-07 21:09:41 +02:00
Gauvain
64d34a9354 feat: Adds separate Android TV and iOS TV build workflows (#907) 2025-08-07 16:08:40 +02:00
Edmond
2a2ecf0526 feat: Add new translation for Traditional Chinese (zh-TW) (#796) 2025-08-07 13:26:02 +02:00
Ferran
a77c7e8e3c feat(lang): add Catalan localization support (#873)
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 13:19:48 +02:00
Gauvain
88791eccf9 fix: Adds conditional check to validate PR title job (#901) 2025-08-07 13:01:32 +02:00
Gauvain
515f7ea26d fix: only run iOS build if it’s on a branch of the repo (#872) 2025-08-07 13:01:22 +02:00
Nguyen Quang Huy
e83bbf3121 feat: Added Vietnamese translation (#834) 2025-08-07 13:01:01 +02:00
lance chant
89b34eddc1 fix: tv playback (#820)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Signed-off-by: lancechant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-08-07 10:12:40 +02:00
Gauvain
89fd7f0e34 fix: add expo-doctor, fixed a warning (#895) 2025-08-06 21:46:16 +02:00
Gauvain
ab9ae5b620 fix(deps): update biome (#894) 2025-08-06 21:45:58 +02:00
retardgerman
a9c519971e fix: loading conditionals (#753) (#805) 2025-08-05 11:23:14 +02:00
88 changed files with 2923 additions and 2189 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(bun install:*)",
"Bash(bunx expo prebuild:*)",
"Bash(bunx expo run:*)",
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
],
"deny": []
}
}

View File

@@ -0,0 +1,7 @@
---
description: Don't write code directly in the ios folder.
globs:
alwaysApply: true
---
We never write code directly in the ios folder. This code is generated by expo plugins.

View File

@@ -1,4 +1,4 @@
name: 🤖 Android APK Build
name: 🤖 Android APK Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,12 +12,17 @@ on:
branches: [develop, master]
jobs:
build:
build-android:
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -30,40 +35,40 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
- name: ☕ Setup JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install dependencies
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Android dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
android/.gradle
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
path: android/.gradle
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-android-deps-
- name: 🛠️ Generate project files
run: bun run prebuild
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🚀 Build APK via Bun
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: bun run build:android:local
- name: 📅 Set date tag
@@ -72,8 +77,9 @@ jobs:
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-apk-${{ env.DATE_TAG }}
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: 🤖 iOS IPA Build
name: 🤖 iOS IPA Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,14 +12,20 @@ on:
branches: [develop, master]
jobs:
build:
build-ios:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Check out repository
- name: 📥 Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -30,33 +36,39 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install & Prepare
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: 16.7.1
eas-version: 16.17.4
token: ${{ secrets.EXPO_TOKEN }}
- name: 🏗️ Build iOS app
run: |
eas build -p ios --local --non-interactive
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -64,7 +76,7 @@ jobs:
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ipa-${{ env.DATE_TAG }}
path: |
build-*.ipa
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7

View File

@@ -29,10 +29,10 @@ jobs:
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.bun/install/cache

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8

View File

@@ -1,10 +1,12 @@
name: 🚦 Security & Quality Gate
on:
pull_request_target:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
branches: [develop]
permissions:
contents: read
@@ -12,6 +14,7 @@ permissions:
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
@@ -61,6 +64,28 @@ jobs:
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
@@ -70,6 +95,7 @@ jobs:
command:
- "lint"
- "check"
- "format"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -86,7 +112,7 @@ jobs:
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: '1.2.17'
bun-version: latest
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -4,6 +4,9 @@ module.exports = ({ config }) => {
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
return {
android: {

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.29.6",
"version": "0.29.13",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -29,18 +29,19 @@
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-plain.png",
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
}
},
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 57,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-plain.png",
"monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#464646"
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
"backgroundColor": "#2E2E2E"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
@@ -72,10 +73,7 @@
{
"ios": {
"deploymentTarget": "15.6",
"extraPods": [
{ "name": "SDWebImage", "modular_headers": true },
{ "name": "SDWebImageSVGCoder", "modular_headers": true }
]
"useFrameworks": "static"
},
"android": {
"compileSdkVersion": 35,
@@ -120,12 +118,11 @@
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png",
"image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100
}
],
@@ -136,12 +133,7 @@
"color": "#9333EA"
}
],
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
],
"experiments": {

View File

@@ -10,7 +10,7 @@ export default function SearchLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerLargeStyle: {

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"),
headerBlurEffect: "prominent",

View File

@@ -3,7 +3,7 @@ import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
@@ -19,7 +19,9 @@ export default function page() {
);
return (
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
@@ -49,42 +51,50 @@ export default function page() {
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons name='cloud-download-outline' size={32} color='white' />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
{!Platform.isTV && (
<>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons
name='cloud-download-outline'
size={32}
color='white'
/>
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'>
<View
style={{
@@ -99,19 +109,22 @@ export default function page() {
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className='text-purple-600'
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
{t("home.intro.read_more")}
</Text>
</Text>
<Text className='text-xs text-purple-600 underline'>
{t("home.intro.read_more")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>

View File

@@ -434,8 +434,6 @@ const TranscodingStreamView = ({
isTranscoding,
properties,
transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => {
return (
<View className='flex flex-col pt-2 first:pt-0'>

View File

@@ -122,7 +122,7 @@ export default function page() {
{new Date(log.timestamp).toLocaleString()}
</Text>
</View>
<Text uiTextView selectable className='text-xs'>
<Text selectable className='text-xs'>
{log.message}
</Text>
</TouchableOpacity>

View File

@@ -17,7 +17,7 @@ export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr();
const { companyId, name, image, type } = local as unknown as {
const { companyId, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;

View File

@@ -221,11 +221,7 @@ const Page: React.FC = () => {
| TvDetails
}
/>
<Text
uiTextView
selectable
className='font-bold text-2xl mb-1'
>
<Text selectable className='font-bold text-2xl mb-1'>
{mediaTitle}
</Text>
<Text className='opacity-50'>{releaseYear}</Text>
@@ -256,26 +252,28 @@ const Page: React.FC = () => {
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'>
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons
name='warning-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
@@ -333,92 +331,95 @@ const Page: React.FC = () => {
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
{!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</BottomSheetView>
</BottomSheetModal>
)}
</View>
);
};

View File

@@ -21,14 +21,13 @@ export default function page() {
const {
jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({
const { data } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),

View File

@@ -1,8 +1,8 @@
import type {
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,

View File

@@ -24,14 +24,6 @@ export default function page() {
const [date, _setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1);
const { data: guideInfo } = useQuery({
queryKey: ["livetv", "guideInfo"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getGuideInfo();
return res.data;
},
});
const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage],
queryFn: async () => {

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.library"),
headerBlurEffect: "prominent",
@@ -200,7 +200,7 @@ export default function IndexLayout() {
name='[libraryId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -213,7 +213,7 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -13,7 +13,7 @@ export default function SearchLayout() {
<Stack.Screen
name='index'
options={{
headerShown: true,
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.search"),
headerLargeStyle: {
@@ -31,7 +31,7 @@ export default function SearchLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -20,6 +20,7 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
@@ -257,6 +258,26 @@ export default function search() {
paddingRight: insets.right,
}}
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{

View File

@@ -59,7 +59,7 @@ export default function TabLayout() {
>
<NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen
listeners={({ navigation }) => ({
listeners={(_e) => ({
tabPress: (_e) => {
eventBus.emit("scrollToTop");
},
@@ -69,7 +69,7 @@ export default function TabLayout() {
title: t("tabs.home"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/house.fill.png")
? (_e) => require("@/assets/icons/house.fill.png")
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
@@ -77,7 +77,7 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
listeners={({ navigation }) => ({
listeners={(_e) => ({
tabPress: (_e) => {
eventBus.emit("searchTabPressed");
},
@@ -87,7 +87,7 @@ export default function TabLayout() {
title: t("tabs.search"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/magnifyingglass.png")
? (_e) => require("@/assets/icons/magnifyingglass.png")
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
@@ -116,7 +116,7 @@ export default function TabLayout() {
title: t("tabs.library"),
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/server.rack.png")
? (_e) => require("@/assets/icons/server.rack.png")
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
@@ -130,7 +130,7 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon:
Platform.OS === "android"
? ({ focused }) => require("@/assets/icons/list.png")
? (_e) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-native/no-inline-styles */
import {
type BaseItemDto,
type MediaSourceInfo,
@@ -13,7 +14,14 @@ import {
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
@@ -47,49 +55,74 @@ const downloadProvider = !Platform.isTV
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
export default function page() {
/* Playback state reducer to consolidate related state */
interface VideoState {
isPlaying: boolean;
isMuted: boolean;
isBuffering: boolean;
isVideoLoaded: boolean;
isPipStarted: boolean;
}
type VideoAction =
| { type: "PLAYING_CHANGED"; value: boolean }
| { type: "BUFFERING_CHANGED"; value: boolean }
| { type: "VIDEO_LOADED" }
| { type: "MUTED_CHANGED"; value: boolean }
| { type: "PIP_CHANGED"; value: boolean };
const videoReducer = (state: VideoState, action: VideoAction): VideoState => {
switch (action.type) {
case "PLAYING_CHANGED":
return { ...state, isPlaying: action.value };
case "BUFFERING_CHANGED":
return { ...state, isBuffering: action.value };
case "VIDEO_LOADED":
// Mark video as loaded and buffering false here
return { ...state, isVideoLoaded: true, isBuffering: false };
case "MUTED_CHANGED":
return { ...state, isMuted: action.value };
case "PIP_CHANGED":
return { ...state, isPipStarted: action.value };
default:
return state;
}
};
const initialVideoState: VideoState = {
isPlaying: false,
isMuted: false,
isBuffering: true,
isVideoLoaded: false,
isPipStarted: false,
};
export default function DirectPlayerPage() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const { t } = useTranslation();
/* Consolidated video playback state */
const [videoState, dispatch] = useReducer(videoReducer, initialVideoState);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
// Load persisted state from storage
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
return saved ?? false;
return storage.getBoolean(IGNORE_SAFE_AREAS_KEY) ?? false;
});
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [isPipStarted, setIsPipStarted] = useState(false);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const insets = useSafeAreaInsets();
const [settings] = useSettings();
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const lightHapticFeedback = useHaptic("light");
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
const getDownloadedItem = downloadProvider.useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const {
itemId,
audioIndex: audioIndexStr,
@@ -105,71 +138,82 @@ export default function page() {
mediaSourceId: string;
bitrateValue: string;
offline: string;
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10)
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const setShowControls = useCallback(
(show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
},
[lightHapticFeedback],
);
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
/* Fetch the item info */
const [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({
isLoading: true,
isError: false,
});
const getDownloadedItem = downloadProvider.useDownload();
/** Gets the initial playback position from the URL or the item's user data. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
return parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item]);
useEffect(() => {
const fetchItemData = async () => {
if (!itemId) return;
const controller = new AbortController();
(async () => {
setItemStatus({ isLoading: true, isError: false });
try {
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
fetchedItem = data?.item as BaseItemDto | null;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
const res = await getUserLibraryApi(api!).getItem(
{ itemId, userId: user?.Id },
{ signal: controller.signal },
);
fetchedItem = res.data;
}
setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
if (!controller.signal.aborted) {
setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
}
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
if (!controller.signal.aborted) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
}
}
};
})();
if (itemId) {
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
return () => controller.abort();
}, [itemId, offline, api, user?.Id, getDownloadedItem]);
/* Fetch stream info */
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
@@ -179,17 +223,16 @@ export default function page() {
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try {
const native = await generateDeviceProfile();
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
result = { mediaSource: data.mediaSource, sessionId: "", url };
} else if (item) {
const res = await getStreamUrl({
api,
item,
@@ -197,7 +240,7 @@ export default function page() {
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
@@ -212,6 +255,7 @@ export default function page() {
}
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) {
@@ -219,219 +263,310 @@ export default function page() {
setStreamStatus({ isLoading: false, isError: true });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
useEffect(() => {
if (!stream) return;
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
reportPlaybackStart();
}, [stream]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
reportPlaybackProgress();
} else {
videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
}
};
const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
revalidateProgressCache();
}, [
itemId,
mediaSourceId,
bitrateValue,
api,
item,
mediaSourceId,
stream,
progress,
user?.Id,
offline,
revalidateProgressCache,
getInitialPlaybackTicks,
audioIndex,
subtitleIndex,
]);
const stop = useCallback(() => {
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return;
/* Memoized playback state info for reporting */
const currentPlayStateInfo = useMemo(() => {
if (!stream) return null;
return {
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
audioStreamIndex: audioIndex,
subtitleStreamIndex: subtitleIndex,
mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
isPaused: !videoState.isPlaying,
playMethod: stream.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: isMuted,
isMuted: videoState.isMuted,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
progress.set(currentTime);
// Update the playback position in the URL.
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
if (offline) return;
if (!item?.Id || !stream) return;
reportPlaybackProgress();
},
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
videoState.isPlaying,
videoState.isMuted,
stream,
]);
/** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => {
if (offline) return 0;
return ticksToSeconds(getInitialPlaybackTicks());
}, [offline, getInitialPlaybackTicks]);
/* Playback progress reporting */
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream || !currentPlayStateInfo) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo as PlaybackProgressInfo,
});
}, [api, offline, stream, currentPlayStateInfo]);
/* Report playback stopped */
const reportPlaybackStopped = useCallback(async () => {
if (offline || !stream) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId,
positionTicks: msToTicks(progress.get()),
playSessionId: stream.sessionId,
});
revalidateProgressCache();
}, [
api,
item?.Id,
mediaSourceId,
progress,
stream,
offline,
revalidateProgressCache,
]);
/* Toggle play/pause */
const togglePlay = useCallback(async () => {
lightHapticFeedback();
const playing = videoState.isPlaying;
dispatch({ type: "PLAYING_CHANGED", value: !playing });
if (playing) {
await videoRef.current?.pause();
reportPlaybackProgress();
} else {
await videoRef.current?.play();
if (currentPlayStateInfo) {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo as PlaybackStartInfo,
});
}
}
}, [
videoState.isPlaying,
lightHapticFeedback,
reportPlaybackProgress,
api,
currentPlayStateInfo,
]);
/* Stop playback and clean up */
const stop = useCallback(() => {
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.stop();
}, [reportPlaybackStopped]);
useEffect(() => {
const unsubscribe = navigation.addListener("beforeRemove", stop);
return unsubscribe;
}, [navigation, stop]);
/* VLC init options optimized for performance */
const optimizedInitOptions = useMemo(() => {
const opts = [`--sub-text-scale=${settings.subtitleSize}`];
// Reduce buffering memory usage
opts.push("--network-caching=300", "--file-caching=300");
if (Platform.OS === "android") opts.push("--aout=opensles");
if (Platform.OS === "ios") opts.push("--ios-hw-decoding");
// Pre-selection of audio & subtitle tracks handled here
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const allAudio =
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Audio") ?? [];
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
)?.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) ?? [];
if (subtitleIndex >= 0) {
const chosenSubtitleTrack = allSubs.find(
(s) => s.Index === subtitleIndex,
);
const textSubs = allSubs.filter((s) => s.IsTextSubtitleStream);
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIdx = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
opts.push(`--sub-track=${finalIdx}`);
}
}
if (notTranscoding && audioIndex !== undefined) {
const chosenAudioTrack = allAudio.find((a) => a.Index === audioIndex);
if (chosenAudioTrack)
opts.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
return opts;
}, [settings.subtitleSize, stream?.mediaSource, subtitleIndex, audioIndex]);
/* On Picture-In-Picture started or stopped */
const onPipStarted = useCallback((e: PipStartedPayload) => {
dispatch({ type: "PIP_CHANGED", value: e.nativeEvent.pipStarted });
}, []);
/* Progress event handler */
const onProgress = useCallback(
(data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
if (videoState.isBuffering)
dispatch({ type: "BUFFERING_CHANGED", value: false });
const { currentTime } = data.nativeEvent;
progress.set(currentTime);
router.setParams({ playbackPosition: msToTicks(currentTime).toString() });
if (!offline) reportPlaybackProgress();
},
[
isSeeking,
isPlaybackStopped,
progress,
offline,
reportPlaybackProgress,
videoState.isBuffering,
],
);
/* Playback state changes */
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
switch (state) {
case "Playing":
dispatch({ type: "PLAYING_CHANGED", value: true });
await activateKeepAwakeAsync();
reportPlaybackProgress();
break;
case "Paused":
dispatch({ type: "PLAYING_CHANGED", value: false });
await deactivateKeepAwake();
reportPlaybackProgress();
break;
default:
dispatch({ type: "BUFFERING_CHANGED", value: !!isBuffering });
dispatch({ type: "PLAYING_CHANGED", value: !!isPlaying });
}
},
[reportPlaybackProgress],
);
/* Safe wrapper for player methods that skips calls if video not loaded */
const safeMethod =
<T extends unknown[]>(
fn: ((...args: T) => any) | undefined,
name: string,
) =>
async (...args: T) => {
// New safeguard: skip calling if video not loaded yet
if (!videoState.isVideoLoaded) {
writeToLog("WARN", `${name} skipped - video not loaded yet`);
return;
}
if (!fn) {
writeToLog("ERROR", `${name} fn missing`, {
isVideoLoaded: videoState.isVideoLoaded,
});
return;
}
try {
return await fn(...args);
} catch (error) {
writeToLog("ERROR", `Error in ${name}`, {
error,
isVideoLoaded: videoState.isVideoLoaded,
});
}
};
const play = useCallback(
() => safeMethod(videoRef.current?.play, "play")(),
[videoRef],
);
const pause = useCallback(
() => safeMethod(videoRef.current?.pause, "pause")(),
[videoRef],
);
const startPictureInPicture = useCallback(
() => safeMethod(videoRef.current?.startPictureInPicture, "PiP")(),
[videoRef],
);
const seek = useCallback(
(t: number) => safeMethod(videoRef.current?.seekTo, "seek")(t),
[videoRef],
);
const getAudioTracks = useCallback(
() => safeMethod(videoRef.current?.getAudioTracks, "getAudioTracks")(),
[videoRef],
);
const getSubtitleTracks = useCallback(
() =>
safeMethod(videoRef.current?.getSubtitleTracks, "getSubtitleTracks")(),
[videoRef],
);
const setAudioTrack = useCallback(
(i: number) =>
safeMethod(videoRef.current?.setAudioTrack, "setAudioTrack")(i),
[videoRef],
);
const setSubtitleTrack = useCallback(
(i: number) =>
safeMethod(videoRef.current?.setSubtitleTrack, "setSubtitleTrack")(i),
[videoRef],
);
const setSubtitleURL = useCallback(
(url: string, n: string) =>
safeMethod(videoRef.current?.setSubtitleURL, "setSubtitleURL")(url, n),
[videoRef],
);
/* Volume handlers */
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
const { volume } = await VolumeManager.getVolume();
await VolumeManager.setVolume(Math.min(volume + 0.1, 1));
}, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100;
if (currentVolumePercent > 0) {
// Currently not muted, so mute
setPreviousVolume(currentVolumePercent);
await VolumeManager.setVolume(0);
setIsMuted(true);
} else {
// Currently muted, so restore previous volume
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
await VolumeManager.setVolume(volumeToRestore / 100);
setPreviousVolume(null);
setIsMuted(false);
}
} catch (error) {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
const { volume } = await VolumeManager.getVolume();
await VolumeManager.setVolume(Math.max(volume - 0.1, 0));
}, []);
const setVolumeCb = useCallback(async (newVolume: number) => {
const setVolumeCb = useCallback(async (v: number) => {
if (Platform.isTV) return;
try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume);
await VolumeManager.setVolume(clampedVolume / 100);
} catch (error) {
console.error("Error setting volume:", error);
}
await VolumeManager.setVolume(Math.max(0, Math.min(v, 100)) / 100);
}, []);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
const { volume } = await VolumeManager.getVolume();
const percent = volume * 100;
if (percent > 0) {
setPreviousVolume(percent);
await VolumeManager.setVolume(0);
dispatch({ type: "MUTED_CHANGED", value: true });
} else {
const restore = previousVolume || 50;
await VolumeManager.setVolume(restore / 100);
setPreviousVolume(null);
dispatch({ type: "MUTED_CHANGED", value: false });
}
}, [previousVolume]);
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
isPlaying: videoState.isPlaying,
togglePlay,
stopPlayback: stop,
offline,
toggleMute: toggleMuteCb,
@@ -440,107 +575,44 @@ export default function page() {
setVolume: setVolumeCb,
});
const onPlaybackStateChanged = useCallback(
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") {
setIsPlaying(false);
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[reportPlaybackProgress],
/* Calculate start position in seconds */
const startPosition = useMemo(
() => (offline ? 0 : ticksToSeconds(getInitialPlaybackTicks())),
[offline, getInitialPlaybackTicks],
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
/* Conditionally render based on loading and error state */
if (itemStatus.isError || streamStatus.isError) {
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<View className='w-screen h-screen items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
return (
<View className='w-screen h-screen items-center justify-center bg-black'>
<Loader />
</View>
);
}
if (itemStatus.isError || streamStatus.isError)
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
const allSubs =
stream?.mediaSource.MediaStreams?.filter((s) => s.Type === "Subtitle") ||
[];
const externalSubtitles = allSubs
.filter((s) => s.DeliveryMethod === "External")
.map((s) => ({
name: s.DisplayTitle,
DeliveryUrl: api?.basePath + s.DeliveryUrl,
}));
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
@@ -548,21 +620,20 @@ export default function page() {
<VlcPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
uri: stream.url,
autoplay: true,
isNetwork: true,
startPosition,
externalSubtitles,
initOptions,
initOptions: optimizedInitOptions,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
// Mark video as loaded on load end to enable player method calls safely
onVideoLoadEnd={() => dispatch({ type: "VIDEO_LOADED" })}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
@@ -573,36 +644,42 @@ export default function page() {
}}
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true && item ? (
{!videoState.isPipStarted && (
<Controls
mediaSource={stream?.mediaSource}
mediaSource={stream.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isPlaying={videoState.isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
isBuffering={videoState.isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
isVideoLoaded={videoState.isVideoLoaded}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay
// Pass undefined for player methods until the video is loaded to avoid crashes
getAudioTracks={videoState.isVideoLoaded ? getAudioTracks : undefined}
getSubtitleTracks={
videoState.isVideoLoaded ? getSubtitleTracks : undefined
}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
setSubtitleTrack={
videoState.isVideoLoaded ? setSubtitleTrack : undefined
}
setSubtitleURL={videoState.isVideoLoaded ? setSubtitleURL : undefined}
setAudioTrack={videoState.isVideoLoaded ? setAudioTrack : undefined}
isVlc
/>
) : null}
)}
</View>
);
}

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
import { type PropsWithChildren } from "react";
/**
* This file is web-only and used to configure the root HTML for every web page during static rendering.

View File

@@ -146,91 +146,99 @@ if (!Platform.isTV) {
console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
try {
const settingsData = storage.getString("settings");
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
console.log("TaskManager ~ Active jobs: ", jobs.length);
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = `${url}download/${job.id}`;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (tasks.find((task: { id: string }) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
BackGroundDownloader.download({
id: job.id,
url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
BackGroundDownloader.completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: "/downloads",
},
},
trigger: null,
});
})
.error((error: any) => {
console.log("TaskManager ~ Download error: ", job.id, error);
BackGroundDownloader.completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: "/downloads",
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
} catch (error) {
console.error("Background task error:", error);
return BackgroundFetch.BackgroundFetchResult.Failed;
}
});
}

View File

@@ -291,7 +291,7 @@ const Login: React.FC = () => {
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/StreamyFinFinal.png")}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -7,15 +7,27 @@ declare module "react-native-mmkv" {
}
}
// Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses
MMKV.prototype.get = function <T>(key: string): T | undefined {
const serializedItem = this.getString(key);
return serializedItem ? JSON.parse(serializedItem) : undefined;
try {
const serializedItem = this.getString(key);
if (!serializedItem) return undefined;
return JSON.parse(serializedItem);
} catch (error) {
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
return undefined;
}
};
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) {
this.delete(key);
} else {
this.set(key, JSON.stringify(value));
try {
if (value === undefined) {
this.delete(key);
} else {
this.set(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Failed to set MMKV value for key "${key}":`, error);
}
};

View File

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

1649
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform, type ViewProps } from "react-native";
import { Platform } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -11,12 +11,6 @@ import GoogleCast, {
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({
width = 48,
height = 48,

View File

@@ -1 +0,0 @@
export * from "zeego/context-menu";

View File

@@ -1,10 +1,8 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
@@ -17,7 +15,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -37,12 +34,7 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation();
const [colorAtom] = useAtom(itemThemeColorAtom);
const _api = useAtomValue(apiAtom);
const _user = useAtomValue(userAtom);
const router = useRouter();

View File

@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
};
});
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam: (lastPage, pages) => {

View File

@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {

View File

@@ -1,10 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
},
) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
@@ -16,7 +11,7 @@ export function Text(
);
return (
<UITextView
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}

View File

@@ -60,9 +60,9 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload();
const { startDownload } = useDownload();
const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const { removeProcess } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();

View File

@@ -23,7 +23,7 @@ interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();

View File

@@ -72,7 +72,6 @@ export const FilterSheet = <T,>({
renderItemLabel,
showSearch = true,
multiple = false,
...props
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);

View File

@@ -50,11 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => {
const {
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
useJellyseerr();
const { t } = useTranslation();
const releases = useMemo(

View File

@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets();

View File

@@ -38,16 +38,7 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
},
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
ref,
) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();

View File

@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
[slide],
);
const { data, isFetching, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(

View File

@@ -11,11 +11,7 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: details,
isLoading,
isError,
} = useQuery({
const { data: details } = useQuery({
queryKey: [
"jellyseerr",
"detail",
@@ -57,11 +53,7 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
}) => {
const { jellyseerrApi } = useJellyseerr();
const {
data: requests,
isLoading,
isError,
} = useQuery({
const { data: requests } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,

View File

@@ -82,7 +82,6 @@ const ListItemContent = ({
showArrow,
iconAfter,
children,
...props
}: Props) => {
return (
<>

View File

@@ -9,7 +9,7 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView selectable className='font-bold text-2xl mb-1'>
<Text selectable className='font-bold text-2xl mb-1'>
{item?.Name}
</Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text>

View File

@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1);

View File

@@ -40,7 +40,7 @@ export const SearchItemWrapper = <T,>({
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({ item, index }) => (item ? renderItem(item) : <></>)}
renderItem={({ item }) => (item ? renderItem(item) : null)}
/>
</>
);

View File

@@ -12,7 +12,7 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text uiTextView className='font-bold text-2xl' selectable>
<Text className='font-bold text-2xl' selectable>
{item?.Name}
</Text>
<View className='flex flex-row items-center mb-1'>

View File

@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
);
};
const RenderItem = ({ item, index }: any) => {
const RenderItem = ({ item }: any) => {
const {
jellyseerrApi,
jellyseerrRegion: region,

View File

@@ -27,7 +27,7 @@ type Props = {
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);

View File

@@ -10,7 +10,7 @@ import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings();
const { t } = useTranslation();

View File

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

View File

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

View File

@@ -14,12 +14,8 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
} = useJellyseerr();
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
const { t } = useTranslation();

View File

@@ -16,7 +16,7 @@ export const StorageSettings = () => {
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
const { data: size } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage;

View File

@@ -1,16 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useEffect, useRef } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
import type { VolumeResult } from "react-native-volume-manager";
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
import { Ionicons } from "@expo/vector-icons";
import type { VolumeResult } from "react-native-volume-manager";
interface AudioSliderProps {
setVisibility: (show: boolean) => void;
}
@@ -47,7 +46,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => {
volume.value = value;
await VolumeManager.setVolume(value / 100);
// await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
VolumeManager.showNativeVolumeUI({ enabled: false });
@@ -55,7 +54,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => {
if (isTv) return;
const volumeListener = VolumeManager.addVolumeListener(
const _volumeListener = VolumeManager.addVolumeListener(
(result: VolumeResult) => {
volume.value = result.volume * 100;
setVisibility(true);
@@ -73,7 +72,7 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
);
return () => {
volumeListener.remove();
// volumeListener.remove();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

View File

@@ -20,6 +20,7 @@ import {
import {
Platform,
TouchableOpacity,
useTVEventHandler,
useWindowDimensions,
View,
} from "react-native";
@@ -156,6 +157,134 @@ export const Controls: FC<Props> = ({
prefetchAllTrickplayImages();
}, []);
const remoteScrubProgress = useSharedValue<number | null>(null);
const isRemoteScrubbing = useSharedValue(false);
const SCRUB_INTERVAL = isVlc ? secondsToMs(10) : msToTicks(secondsToMs(10));
const [showRemoteBubble, setShowRemoteBubble] = useState(false);
const [longPressScrubMode, setLongPressScrubMode] = useState<
"FF" | "RW" | null
>(null);
useTVEventHandler((evt) => {
if (!evt) return;
switch (evt.eventType) {
case "longLeft": {
setLongPressScrubMode((prev) => (!prev ? "RW" : null));
break;
}
case "longRight": {
setLongPressScrubMode((prev) => (!prev ? "FF" : null));
break;
}
case "left":
case "right": {
isRemoteScrubbing.value = true;
setShowRemoteBubble(true);
const direction = evt.eventType === "left" ? -1 : 1;
const base = remoteScrubProgress.value ?? progress.value;
const updated = Math.max(
min.value,
Math.min(max.value, base + direction * SCRUB_INTERVAL),
);
remoteScrubProgress.value = updated;
const progressInTicks = isVlc ? msToTicks(updated) : updated;
calculateTrickplayUrl(progressInTicks);
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
break;
}
case "select": {
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
progress.value = remoteScrubProgress.value;
const seekTarget = isVlc
? Math.max(0, remoteScrubProgress.value)
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
seek(seekTarget);
if (isPlaying) play();
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
} else {
togglePlay();
}
break;
}
case "down":
case "up":
// cancel scrubbing on other directions
isRemoteScrubbing.value = false;
remoteScrubProgress.value = null;
setShowRemoteBubble(false);
break;
default:
break;
}
if (!showControls) toggleControls();
});
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
let isActive = true;
let seekTime = 10;
const scrubWithLongPress = () => {
if (!isActive || !longPressScrubMode) return;
setIsSliding(true);
const scrubFn =
longPressScrubMode === "FF" ? handleSeekForward : handleSeekBackward;
scrubFn(seekTime);
seekTime *= 1.1;
longPressTimeoutRef.current = setTimeout(scrubWithLongPress, 300);
};
if (longPressScrubMode) {
isActive = true;
scrubWithLongPress();
}
return () => {
isActive = false;
setIsSliding(false);
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}
};
}, [longPressScrubMode]);
const effectiveProgress = useSharedValue(0);
// Recompute progress whenever remote scrubbing is active
useAnimatedReaction(
() => ({
isScrubbing: isRemoteScrubbing.value,
scrub: remoteScrubProgress.value,
actual: progress.value,
}),
(current) => {
effectiveProgress.value =
current.isScrubbing && current.scrub != null
? current.scrub
: current.actual;
},
[],
);
useEffect(() => {
if (item) {
progress.value = isVlc
@@ -374,20 +503,19 @@ export const Controls: FC<Props> = ({
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
}, [showControls, isPlaying, pause]);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current) {
play();
}
},
[isVlc],
[isVlc, seek, play],
);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
@@ -424,7 +552,43 @@ export const Controls: FC<Props> = ({
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying, isVlc]);
}, [settings, isPlaying, isVlc, play, seek]);
const handleSeekBackward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? Math.max(0, curr - secondsToMs(seconds))
: Math.max(0, ticksToSeconds(curr) - seconds);
seek(newTime);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSeekForward = useCallback(
async (seconds: number) => {
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(seconds)
: ticksToSeconds(curr) + seconds;
seek(Math.max(0, newTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
},
[isPlaying, isVlc, seek],
);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) {
@@ -446,7 +610,7 @@ export const Controls: FC<Props> = ({
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying, isVlc]);
}, [settings, isPlaying, isVlc, play, seek]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
@@ -667,80 +831,87 @@ export const Controls: FC<Props> = ({
>
<BrightnessSlider />
</View>
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipBackward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
>
<Ionicons
name='refresh-outline'
size={50}
color='white'
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.rewindSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
<View
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
>
<TouchableOpacity
onPress={() => {
togglePlay();
}}
>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
{!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color='white'
style={{
opacity: showControls ? 1 : 0,
}}
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
</View>
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipForward}>
<View
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
position: "relative",
justifyContent: "center",
alignItems: "center",
opacity: showControls ? 1 : 0,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
<Ionicons name='refresh-outline' size={50} color='white' />
<Text
style={{
position: "absolute",
color: "white",
fontSize: 16,
fontWeight: "bold",
bottom: 10,
}}
>
{settings?.forwardSkipTime}
</Text>
</View>
</TouchableOpacity>
)}
<View
style={{
position: "absolute",
@@ -850,10 +1021,12 @@ export const Controls: FC<Props> = ({
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
renderBubble={() =>
(isSliding || showRemoteBubble) && memoizedRenderBubble()
}
sliderHeight={10}
thumbWidth={0}
progress={progress}
progress={effectiveProgress}
minimumValue={min}
maximumValue={max}
/>

View File

@@ -93,7 +93,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
[seasons, seasonIndex],
);
const { data: episodes, isFetching } = useQuery({
const { data: episodes } = useQuery({
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];

View File

@@ -46,14 +46,14 @@
},
"production": {
"environment": "production",
"channel": "0.29.6",
"channel": "0.29.13",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.29.6",
"channel": "0.29.13",
"android": {
"buildType": "apk",
"image": "latest"
@@ -61,7 +61,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.29.6",
"channel": "0.29.13",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -16,34 +16,46 @@ export type HapticFeedbackType =
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings();
const isTv = Platform.isTV;
const isDisabled =
isTv ||
!Haptics ||
settings?.disableHapticFeedback ||
Platform.OS === "web";
const createHapticHandler = useCallback(
(type: typeof Haptics.ImpactFeedbackStyle) => {
return Platform.OS === "web" || Platform.isTV
? () => {}
: () => Haptics.impactAsync(type);
if (!Haptics || !type) return () => {};
return () => Haptics.impactAsync(type);
},
[],
);
const createNotificationFeedback = useCallback(
(type: typeof Haptics.NotificationFeedbackType) => {
return Platform.OS === "web" || Platform.isTV
? () => {}
: () => Haptics.notificationAsync(type);
if (!Haptics || !type) return () => {};
return () => Haptics.notificationAsync(type);
},
[],
);
const hapticHandlers = useMemo(
() => ({
const hapticHandlers = useMemo(() => {
if (!Haptics) {
return {
light: () => {},
medium: () => {},
heavy: () => {},
selection: () => {},
success: () => {},
warning: () => {},
error: () => {},
};
}
return {
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
selection:
Platform.OS === "web" || Platform.isTV
? () => {}
: Haptics.selectionAsync,
selection: Haptics.selectionAsync,
success: createNotificationFeedback(
Haptics.NotificationFeedbackType.Success,
),
@@ -51,16 +63,11 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
Haptics.NotificationFeedbackType.Warning,
),
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
}),
[createHapticHandler, createNotificationFeedback],
);
if (isTv) {
return () => {};
}
};
}, [createHapticHandler, createNotificationFeedback]);
if (settings?.disableHapticFeedback) {
return () => {};
}
return hapticHandlers[feedbackType];
return isDisabled ? () => {} : hapticHandlers[feedbackType];
};

View File

@@ -2,6 +2,7 @@ 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 { getColors, ImageColorsResult } from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
adjustToNearBlack,
@@ -12,9 +13,6 @@ import {
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
// import { getColors } from "react-native-image-colors";
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
/**
* Custom hook to extract and manage image colors for a given item.
*
@@ -65,48 +63,45 @@ export const useImageColors = ({
return;
}
Colors.getColors(source.uri, {
// Extract colors from the image
getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then(
(colors: {
platform: string;
dominant: string;
vibrant: string;
detail: string;
primary: string;
}) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
.then((colors: ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// 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;
}
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(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);
}
if (primary) text = calculateTextColor(primary);
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
setPrimaryColor({
primary,
text,
});
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, 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);
});

View File

@@ -20,7 +20,7 @@ export const useJellyfinDiscovery = () => {
setServers([]);
const discoveredServers = new Set<string>();
let discoveryTimeout: NodeJS.Timeout;
let discoveryTimeout: number;
const socket = dgram.createSocket({
type: "udp4",

View File

@@ -14,12 +14,6 @@ interface TrickplayData {
ThumbnailCount?: number;
}
interface TrickplayInfo {
resolution: string;
aspectRatio: number;
data: TrickplayData;
}
interface TrickplayUrl {
x: number;
y: number;
@@ -38,7 +32,8 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
}
const mediaSourceId = item.Id;
const trickplayData = item.Trickplay[mediaSourceId];
const trickplayData: Record<string, TrickplayData> | undefined =
item.Trickplay[mediaSourceId];
if (!trickplayData) {
return null;

View File

@@ -1,6 +1,7 @@
import { getLocales } from "expo-localization";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import ca from "./translations/ca.json";
import da from "./translations/da.json";
import de from "./translations/de.json";
import en from "./translations/en.json";
@@ -22,10 +23,12 @@ import sv from "./translations/sv.json";
import tlh from "./translations/tlh.json";
import tr from "./translations/tr.json";
import uk from "./translations/uk.json";
import vi from "./translations/vi.json";
import zhCN from "./translations/zh-CN.json";
import zhTW from "./translations/zh-TW.json";
export const APP_LANGUAGES = [
{ label: "Catalan", value: "ca" },
{ label: "Dansk", value: "da" },
{ label: "Deutsch", value: "de" },
{ label: "English", value: "en" },
@@ -49,11 +52,13 @@ export const APP_LANGUAGES = [
{ label: "Українська", value: "uk" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "Tiếng Việt", value: "vi" },
];
i18n.use(initReactI18next).init({
compatibilityJSON: "v4",
resources: {
ca: { translation: ca },
da: { translation: da },
de: { translation: de },
en: { translation: en },
@@ -75,6 +80,7 @@ i18n.use(initReactI18next).init({
tr: { translation: tr },
tlh: { translation: tlh },
uk: { translation: uk },
vi: { translation: vi },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
},

View File

@@ -1,7 +1,19 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
// Add Hermes parser
config.transformer.hermesParser = true;
// When enabled, the optional code below will allow Metro to resolve
// and bundle source files with TV-specific extensions
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
//
// Metro will still resolve source files with standard extensions
// as usual if TV-specific files are not found for a module.
//
if (process.env?.EXPO_TV === "1") {
const originalSourceExts = config.resolver.sourceExts;
const tvSourceExts = [
@@ -11,4 +23,6 @@ if (process.env?.EXPO_TV === "1") {
config.resolver.sourceExts = tvSourceExts;
}
// config.resolver.unstable_enablePackageExports = false;
module.exports = config;

View File

@@ -20,123 +20,122 @@
},
"dependencies": {
"@bottom-tabs/react-navigation": "^0.9.2",
"@expo/config-plugins": "~9.0.15",
"@expo/config-plugins": "~10.1.1",
"@expo/metro-runtime": "~5.0.4",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.10",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-community/netinfo": "11.4.1",
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-menu/menu": "^1.2.3",
"@react-navigation/material-top-tabs": "^7.1.0",
"@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3",
"@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"expo": "~52.0.31",
"expo-asset": "~11.0.3",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-brightness": "~13.0.3",
"expo-build-properties": "~0.13.2",
"expo-constants": "~17.0.5",
"expo-crypto": "~14.0.2",
"expo-dev-client": "~5.0.11",
"expo-device": "~7.0.2",
"expo-font": "~13.0.3",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.4",
"expo-keep-awake": "~14.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-network": "~7.0.5",
"expo-notifications": "~0.29.13",
"expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5",
"expo-updates": "~0.27.4",
"expo-web-browser": "~14.0.2",
"expo": "^53.0.6",
"expo-application": "~6.1.4",
"expo-asset": "~11.1.7",
"expo-background-fetch": "~13.1.5",
"expo-blur": "~14.1.4",
"expo-brightness": "~13.1.4",
"expo-build-properties": "~0.14.6",
"expo-constants": "~17.1.5",
"expo-dev-client": "^5.2.0",
"expo-device": "~7.1.4",
"expo-doctor": "^1.13.5",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.4.0",
"expo-linear-gradient": "~14.1.4",
"expo-linking": "~7.1.4",
"expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2",
"expo-router": "~5.1.4",
"expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.7",
"expo-task-manager": "~13.1.5",
"expo-web-browser": "~14.2.0",
"i18next": "^25.0.0",
"install": "^0.13.0",
"jotai": "^2.11.3",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@~0.77.2-0",
"react-native": "npm:react-native-tvos@0.79.5-0",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^0.9.2",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-compressor": "^1.10.3",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4",
"react-native-edge-to-edge": "^1.4.3",
"react-native-gesture-handler": "2.22.0",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.3",
"react-native-gesture-handler": "~2.24.0",
"react-native-google-cast": "^4.9.0",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0",
"react-native-ios-utilities": "5.1.8",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.5.1",
"react-native-progress": "^5.0.1",
"react-native-mmkv": "2.12.2",
"react-native-reanimated": "~3.16.7",
"react-native-reanimated-carousel": "^4",
"react-native-safe-area-context": "5.5.0",
"react-native-screens": "~4.5.0",
"react-native-svg": "15.11.1",
"react-native-tab-view": "^4.0.5",
"react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3",
"react-native-video": "6.16.1",
"react-native-video": "6.14.1",
"react-native-volume-manager": "^2.0.8",
"react-native-web": "~0.19.13",
"react-native-webview": "13.13.2",
"react-native-web": "^0.20.0",
"sonner-native": "^0.21.0",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"uuid": "^11.0.5",
"zeego": "^3",
"zeego": "^3.0.6",
"zod": "^3.24.1"
},
"devDependencies": {
"@babel/core": "^7.26.8",
"@biomejs/biome": "^2.1.2",
"@babel/core": "^7.20.0",
"@biomejs/biome": "^2.1.4",
"@react-native-community/cli": "^19",
"@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15",
"@types/react": "~18.3.12",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"cross-env": "^10",
"cross-env": "^10.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.2",
"lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1",
"typescript": "~5.8.0"
"typescript": "~5.8.3"
},
"private": true,
"expo": {
"install": {
"exclude": [
"react-native"
"react-native",
"@shopify/flash-list",
"react-native-reanimated"
]
},
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"react-native-google-cast",
"react-native-udp",
"@bottom-tabs/react-navigation",
"@jellyfin/sdk",
"expo-doctor"
],
"listUnknownPackages": false
}
}
},
"private": true,
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"biome check --write --unsafe --no-errors-on-unmatched"

View File

@@ -0,0 +1,66 @@
const { withPodfile } = require("expo/config-plugins");
const PATCH_START = "## >>> runtime-framework headers";
const PATCH_END = "## <<< runtime-framework headers";
const EXTRA_HDRS = [
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeApple/React_RuntimeApple.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-RuntimeCore/React_RuntimeCore.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jserrorhandler/React_jserrorhandler.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-jsinspector/jsinspector_modern.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-runtimescheduler/React_runtimescheduler.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-performancetimeline/React_performancetimeline.framework/Headers`,
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
];
function buildPatch() {
return [
PATCH_START,
" extra_hdrs = [",
...EXTRA_HDRS.map((h) => ` "${h}",`),
" ]",
"",
" installer.pods_project.targets.each do |t|",
" t.build_configurations.each do |cfg|",
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
" end",
" end",
PATCH_END,
].join("\n");
}
module.exports = function withRuntimeFrameworkHeaders(config) {
return withPodfile(config, (config) => {
let podfile = config.modResults.contents;
// 1⃣ ensure there's a post_install block
if (!/^\s*post_install\s+do\s+\|installer\|/m.test(podfile)) {
podfile += `
post_install do |installer|
end
`;
}
const patch = buildPatch();
if (podfile.includes(PATCH_START)) {
// 🔄 update existing patch
podfile = podfile.replace(
new RegExp(`${PATCH_START}[\\s\\S]*?${PATCH_END}`),
patch,
);
} else {
// insert right after the post_install opening line
podfile = podfile.replace(
/^\s*post_install\s+do\s+\|installer\|.*$/m,
(match) => `${match}\n\n${patch}`,
);
}
console.log("✅ with-runtime-framework-headers: Podfile updated");
config.modResults.contents = podfile;
return config;
});
};

View File

@@ -1,48 +1,66 @@
const { withAppDelegate } = require("@expo/config-plugins");
const { withAppDelegate, withXcodeProject } = require("@expo/config-plugins");
const fs = require("node:fs");
const path = require("node:path");
function withRNBackgroundDownloader(expoConfig) {
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
const { modResults: appDelegate } = appDelegateConfig;
const appDelegateLines = appDelegate.contents.split("\n");
// Define the code to be added to AppDelegate.mm
const backgroundDownloaderImport =
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
{
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
}`;
// Find the index of the AppDelegate import statement
const importIndex = appDelegateLines.findIndex((line) =>
/^#import "AppDelegate.h"/.test(line),
);
// Find the index of the last line before the @end statement
const endStatementIndex = appDelegateLines.findIndex((line) =>
/@end/.test(line),
);
// Insert the import statement if it's not already present
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
}
// Insert the delegate method above the @end statement
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
appDelegateLines.splice(
endStatementIndex,
0,
backgroundDownloaderDelegate,
/** @param {import("@expo/config-plugins").ExpoConfig} config */
function withRNBackgroundDownloader(config) {
/* 1⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
config = withAppDelegate(config, (mod) => {
const tag = "handleEventsForBackgroundURLSession";
if (!mod.modResults.contents.includes(tag)) {
mod.modResults.contents = mod.modResults.contents.replace(
/\}\s*$/, // insert before final }
`
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
}
}`,
);
}
// Update the contents of the AppDelegate file
appDelegate.contents = appDelegateLines.join("\n");
return appDelegateConfig;
return mod;
});
/* 2⃣ Ensure bridging header exists & is attached to *every* app target */
config = withXcodeProject(config, (mod) => {
const project = mod.modResults;
const projectName = config.name || "App";
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
const iosDir = path.dirname(path.dirname(project.filepath));
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
const headerAbs = path.join(iosDir, headerRel);
// create / append import if missing
let headerText = "";
try {
headerText = fs.readFileSync(headerAbs, "utf8");
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
if (!headerText.includes("RNBackgroundDownloader.h")) {
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
}
// Expo 53's xcodejs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) =>
project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER",
"Streamyfin/Streamyfin-Bridging-Header.h",
cfg,
),
);
return mod;
});
return config;
}
module.exports = withRNBackgroundDownloader;

View File

@@ -2,7 +2,6 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as Application from "expo-application";
@@ -42,6 +41,10 @@ import {
import { Bitrate } from "../components/BitrateSelector";
import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;

View File

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

20
react-native.config.js Normal file
View File

@@ -0,0 +1,20 @@
// react-native.config.js
//https://docs.expo.dev/modules/autolinking/
const isTV = process.env?.EXPO_TV === "1";
module.exports = {
dependencies: {
"react-native-volume-manager": !isTV
? {
platforms: {
// leaving this blank seems to enable auto-linking which is what we want for mobile
},
}
: {
platforms: {
android: null,
},
},
},
};

484
translations/ca.json Normal file
View File

@@ -0,0 +1,484 @@
{
"login": {
"username_required": "Nom d'usuari requerit",
"error_title": "Error",
"login_title": "Inicia sessió",
"login_to_title": "Inicia sessió a",
"username_placeholder": "Nom d'usuari",
"password_placeholder": "Contrasenya",
"login_button": "Inicia sessió",
"quick_connect": "Connexió ràpida",
"enter_code_to_login": "Introdueix el codi {{code}} per iniciar sessió",
"failed_to_initiate_quick_connect": "No s'ha pogut iniciar la connexió ràpida",
"got_it": "Entesos",
"connection_failed": "Ha fallat la connexió",
"could_not_connect_to_server": "No s'ha pogut connectar amb el servidor. Comproveu l'URL i la connexió de xarxa.",
"an_unexpected_error_occured": "S'ha produït un error inesperat",
"change_server": "Canvia el servidor",
"invalid_username_or_password": "Nom d'usuari o contrasenya incorrectes",
"user_does_not_have_permission_to_log_in": "L'usuari no té permís per iniciar sessió",
"server_is_taking_too_long_to_respond_try_again_later": "El servidor triga massa a respondre, torneu-ho a provar més tard",
"server_received_too_many_requests_try_again_later": "El servidor ha rebut massa sol·licituds, torneu-ho a provar més tard.",
"there_is_a_server_error": "Error del servidor",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "S'ha produït un error inesperat. Heu introduït correctament l'URL del servidor?"
},
"server": {
"enter_url_to_jellyfin_server": "Introdueix l'URL del vostre servidor Jellyfin",
"server_url_placeholder": "http(s)://el-vostre-servidor.com",
"connect_button": "Connecta",
"previous_servers": "servidors anteriors",
"clear_button": "Esborra",
"search_for_local_servers": "Cercar servidors locals",
"searching": "Cercant...",
"servers": "Servidors"
},
"home": {
"no_internet": "Sense internet",
"no_items": "No hi ha elements",
"no_internet_message": "No us preocupeu, encara podeu veure\nel contingut descarregat.",
"go_to_downloads": "Anar a les descàrregues",
"oops": "Oops!",
"error_message": "Alguna cosa ha anat malament.\nTanqueu la sessió i torneu-la a iniciar.",
"continue_watching": "Continua veient",
"next_up": "A continuació",
"recently_added_in": "Afegit recentment a {{libraryName}}",
"suggested_movies": "Pel·lícules suggerides",
"suggested_episodes": "Episodis suggerits",
"intro": {
"welcome_to_streamyfin": "Benvingut a Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Un client gratuït i de codi obert per a Jellyfin.",
"features_title": "Funcionalitats",
"features_description": "Streamyfin té moltes funcionalitats i s'integra amb una gran varietat de programari que podeu trobar al menú de configuració, això inclou:",
"jellyseerr_feature_description": "Connecteu-vos a la vostra instància de Jellyseerr i sol·liciteu pel·lícules directament des de l'aplicació.",
"downloads_feature_title": "Descàrregues",
"downloads_feature_description": "Descarregueu pel·lícules i sèries per veure-les sense connexió. Utilitzeu el mètode per defecte o instal·leu el servidor optimitzat per descarregar fitxers en segon pla.",
"chromecast_feature_description": "Envieu pel·lícules i sèries als vostres dispositius Chromecast.",
"centralised_settings_plugin_title": "Plugin de configuració centralitzada",
"centralised_settings_plugin_description": "Configureu els ajustos des d'una ubicació centralitzada al vostre servidor Jellyfin. Tots els ajustos del client per a tots els usuaris se sincronitzaran automàticament.",
"done_button": "Fet",
"go_to_settings_button": "Ves a la configuració",
"read_more": "Mostra més"
},
"settings": {
"settings_title": "Configuració",
"log_out_button": "Tanca sessió",
"user_info": {
"user_info_title": "Informació de l'usuari",
"user": "Usuari",
"server": "Servidor",
"token": "Token",
"app_version": "Versió de l'aplicació"
},
"quick_connect": {
"quick_connect_title": "Connexió ràpida",
"authorize_button": "Autoritza connexió ràpida",
"enter_the_quick_connect_code": "Introdueix el codi de connexió ràpida...",
"success": "Èxit",
"quick_connect_autorized": "Connexió ràpida autoritzada",
"error": "Error",
"invalid_code": "Codi invàlid",
"authorize": "Autoritza"
},
"media_controls": {
"media_controls_title": "Controls multimèdia",
"forward_skip_length": "Durada del salt endavant",
"rewind_length": "Durada del rebobinat",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Àudio",
"set_audio_track": "Establir pista d'àudio de l'element anterior",
"audio_language": "Idioma de l'àudio",
"audio_hint": "Trieu un idioma d'àudio per defecte.",
"none": "Cap",
"language": "Idioma"
},
"subtitles": {
"subtitle_title": "Subtítols",
"subtitle_language": "Idioma dels subtítols",
"subtitle_mode": "Mode dels subtítols",
"set_subtitle_track": "Establir pista de subtítols de l'element anterior",
"subtitle_size": "Mida dels subtítols",
"subtitle_hint": "Configureu les preferències dels subtítols.",
"none": "Cap",
"language": "Idioma",
"loading": "Carregant",
"modes": {
"Default": "Per defecte",
"Smart": "Intel·ligent",
"Always": "Sempre",
"None": "Cap",
"OnlyForced": "Només els forçats"
}
},
"other": {
"other_title": "Altres",
"follow_device_orientation": "Rotació automàtica",
"video_orientation": "Orientació del vídeo",
"orientation": "Orientació",
"orientations": {
"DEFAULT": "Per defecte",
"ALL": "Totes",
"PORTRAIT": "Vertical",
"PORTRAIT_UP": "Vertical amunt",
"PORTRAIT_DOWN": "Vertical avall",
"LANDSCAPE": "Horitzontal",
"LANDSCAPE_LEFT": "Horitzontal esquerra",
"LANDSCAPE_RIGHT": "Horitzontal dreta",
"OTHER": "Altra",
"UNKNOWN": "Desconeguda"
},
"safe_area_in_controls": "Àrea segura als controls",
"video_player": "Reproductor de vídeo",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
"hide_libraries": "Oculta biblioteques",
"select_liraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
"disable_haptic_feedback": "Desactiva la resposta hàptica",
"default_quality": "Qualitat per defecte",
"max_auto_play_episode_count": "Nombre màxim d'episodis de reproducció automàtica",
"disabled": "Desactivat"
},
"downloads": {
"downloads_title": "Descàrregues",
"download_method": "Mètode de descàrrega",
"remux_max_download": "Màxima descàrrega remux",
"auto_download": "Descàrrega automàtica",
"optimized_versions_server": "Servidor de versions optimitzades",
"save_button": "Desa",
"optimized_server": "Servidor optimitzat",
"optimized": "Optimitzat",
"default": "Per defecte",
"optimized_version_hint": "Introdueix l'URL del servidor d'optimització. L'URL ha d'incloure http o https i opcionalment el port.",
"read_more_about_optimized_server": "Mostra més sobre el servidor d'optimització.",
"url": "URL",
"server_url_placeholder": "http(s)://domini.org:port"
},
"plugins": {
"plugins_title": "Connectors",
"jellyseerr": {
"jellyseerr_warning": "Aquesta integració es troba en una versió primerenca. Espereu que les coses canviïn.",
"server_url": "URL del servidor",
"server_url_hint": "Exemple: http(s)://el-vostre-domini.url\n(afegiu el port si és necessari)",
"server_url_placeholder": "URL de Jellyseerr...",
"password": "Contrasenya",
"password_placeholder": "Introdueix la contrasenya per a l'usuari de Jellyfin {{username}}",
"save_button": "Desa",
"clear_button": "Esborra",
"login_button": "Inicia sessió",
"total_media_requests": "Sol·licituds totals de contingut",
"movie_quota_limit": "Límit de quota de pel·lícules",
"movie_quota_days": "Dies de quota de pel·lícules",
"tv_quota_limit": "Límit de quota de sèries",
"tv_quota_days": "Dies de quota de sèries",
"reset_jellyseerr_config_button": "Restalbeix la configuració de Jellyseerr",
"unlimited": "Il·limitat",
"plus_n_more": "+{{n}} més",
"order_by": {
"DEFAULT": "Per defecte",
"VOTE_COUNT_AND_AVERAGE": "Recompte de vots i mitjana",
"POPULARITY": "Popularitat"
}
},
"marlin_search": {
"enable_marlin_search": "Activa la cerca de Marlin",
"url": "URL",
"server_url_placeholder": "http(s)://domini.org:port",
"marlin_search_hint": "Introdueix l'URL del servidor Marlin. L'URL ha d'incloure http o https i opcionalment el port.",
"read_more_about_marlin": "Mostra més sobre Marlin.",
"save_button": "Desa",
"toasts": {
"saved": "Desat"
}
}
},
"storage": {
"storage_title": "Emmagatzematge",
"app_usage": "Aplicació {{usedSpace}}%",
"device_usage": "Dispositiu {{availableSpace}}%",
"size_used": "{{used}} de {{total}} utilitzat",
"delete_all_downloaded_files": "Suprimeix tots els fitxers descarregats"
},
"intro": {
"show_intro": "Mostra la introducció",
"reset_intro": "Restableix la introducció"
},
"logs": {
"logs_title": "Registres",
"export_logs": "Exporta registres",
"click_for_more_info": "Feu clic per obtenir més informació",
"level": "Nivell",
"no_logs_available": "No hi ha registres disponibles",
"delete_all_logs": "Suprimeix tots els registres"
},
"languages": {
"title": "Idiomes",
"app_language": "Idioma de l'aplicació",
"app_language_description": "Seleccioneu l'idioma de l'aplicació.",
"system": "Sistema"
},
"toasts": {
"error_deleting_files": "Error en suprimir fitxers",
"background_downloads_enabled": "Descàrregues en segon pla activades",
"background_downloads_disabled": "Descàrregues en segon pla desactivades",
"connected": "Connectat",
"could_not_connect": "No s'ha pogut connectar",
"invalid_url": "URL invàlida"
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No hi ha sessions actives"
},
"downloads": {
"downloads_title": "Descàrregues",
"tvseries": "Sèries",
"movies": "Pel·lícules",
"queue": "Cua",
"queue_hint": "La cua i les descàrregues es perdran en reiniciar l'aplicació",
"no_items_in_queue": "No hi ha elements a la cua",
"no_downloaded_items": "No hi ha elements descarregats",
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
"delete_all_tvseries_button": "Suprimeix totes les sèries",
"delete_all_button": "Suprimeix-ho tot",
"active_download": "Descàrrega activa",
"no_active_downloads": "No hi ha descàrregues actives",
"active_downloads": "Descàrregues actives",
"new_app_version_requires_re_download": "La nova versió de l'aplicació requereix tornar a descarregar",
"new_app_version_requires_re_download_description": "L'actualització nova requereix que el contingut es torni a descarregar. Suprimiu tot el contingut descarregat i torneu-ho a provar.",
"back": "Enrere",
"delete": "Suprimeix",
"something_went_wrong": "Alguna cosa ha anat malament",
"could_not_get_stream_url_from_jellyfin": "No s'ha pogut obtenir l'URL del flux de Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Mètodes",
"toasts": {
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
"download_cancelled": "Descàrrega cancel·lada",
"could_not_cancel_download": "No s'ha pogut cancel·lar la descàrrega",
"download_completed": "Descàrrega completada",
"download_started_for": "S'ha iniciat la descàrrega per a {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} està preparat per ser descarregat",
"download_stated_for_item": "S'ha iniciat la descàrrega per a {{item}}",
"download_failed_for_item": "Ha fallat la descàrrega per a {{item}} - {{error}}",
"download_completed_for_item": "S'ha completat la descàrrega per a {{item}}",
"queued_item_for_optimization": "S'ha afegit {{item}} a la cua per a l'optimització",
"failed_to_start_download_for_item": "No s'ha pogut iniciar la descàrrega per a {{item}}: {{message}}",
"server_responded_with_status_code": "El servidor ha respost amb l'estat {{statusCode}}",
"no_response_received_from_server": "No s'ha rebut resposta del servidor",
"error_setting_up_the_request": "Error en configurar la sol·licitud",
"failed_to_start_download_for_item_unexpected_error": "No s'ha pogut iniciar la descàrrega per a {{item}}: Error inesperat",
"all_files_folders_and_jobs_deleted_successfully": "Tots els fitxers, carpetes i treballs s'han suprimit correctament",
"an_error_occured_while_deleting_files_and_jobs": "S'ha produït un error en suprimir fitxers i treballs",
"go_to_downloads": "Ves a les descàrregues"
}
}
},
"search": {
"search_here": "Cerca aquí...",
"search": "Cerca...",
"x_items": "{{count}} elements",
"library": "Biblioteca",
"discover": "Descobreix",
"no_results": "No hi ha resultats",
"no_results_found_for": "No s'han trobat resultats per a",
"movies": "Pel·lícules",
"series": "Sèries",
"episodes": "Episodis",
"collections": "Col·leccions",
"actors": "Actors",
"request_movies": "Sol·licita pel·lícules",
"request_series": "Sol·licita sèries",
"recently_added": "Afegit recentment",
"recent_requests": "Sol·licituds recents",
"plex_watchlist": "Llista de seguiment de Plex",
"trending": "Tendències",
"popular_movies": "Pel·lícules populars",
"movie_genres": "Gèneres de pel·lícules",
"upcoming_movies": "Pròximes pel·lícules",
"studios": "Estudis",
"popular_tv": "Sèries populars",
"tv_genres": "Gèneres de sèries",
"upcoming_tv": "Pròximes sèries",
"networks": "Cadenes",
"tmdb_movie_keyword": "Paraula clau de pel·lícula TMDB",
"tmdb_movie_genre": "Gènere de pel·lícula TMDB",
"tmdb_tv_keyword": "Paraula clau de sèrie TMDB",
"tmdb_tv_genre": "Gènere de sèrie TMDB",
"tmdb_search": "Cerca TMDB",
"tmdb_studio": "Estudi TMDB",
"tmdb_network": "Cadena TMDB",
"tmdb_movie_streaming_services": "Serveis de reproducció de pel·lícules TMDB",
"tmdb_tv_streaming_services": "Serveis de reproducció de sèries TMDB"
},
"library": {
"no_items_found": "No s'han trobat elements",
"no_results": "No hi ha resultats",
"no_libraries_found": "No s'han trobat biblioteques",
"item_types": {
"movies": "pel·lícules",
"series": "sèries",
"boxsets": "col·leccions",
"items": "elements"
},
"options": {
"display": "Visualització",
"row": "Fila",
"list": "Llista",
"image_style": "Estil d'imatge",
"poster": "Cartell",
"cover": "Coberta",
"show_titles": "Mostrar títols",
"show_stats": "Mostrar estadístiques"
},
"filters": {
"genres": "Gèneres",
"years": "Anys",
"sort_by": "Ordenar per",
"sort_order": "Ordre",
"asc": "Ascendent",
"desc": "Descendent",
"tags": "Etiquetes"
}
},
"favorites": {
"series": "Sèries",
"movies": "Pel·lícules",
"episodes": "Episodis",
"videos": "Vídeos",
"boxsets": "Col·leccions",
"playlists": "Llistes de reproducció",
"noDataTitle": "Encara no hi ha preferits",
"noData": "Marqueu elements com a preferits per veure'ls aparèixer aquí per a un accés ràpid."
},
"custom_links": {
"no_links": "No hi ha enllaços"
},
"player": {
"error": "Error",
"failed_to_get_stream_url": "No s'ha pogut obtenir l'URL del flux",
"an_error_occured_while_playing_the_video": "S'ha produït un error en reproduir el vídeo. Consulteu els registres a la configuració.",
"client_error": "Error del client",
"could_not_create_stream_for_chromecast": "No s'ha pogut crear un flux per a Chromecast",
"message_from_server": "Missatge del servidor: {{message}}",
"video_has_finished_playing": "El vídeo ha acabat de reproduir-se!",
"no_video_source": "No hi ha font de vídeo...",
"next_episode": "Episodi següent",
"refresh_tracks": "Actualitzar pistes",
"subtitle_tracks": "Pistes de subtítols:",
"audio_tracks": "Pistes d'àudio:",
"playback_state": "Estat de reproducció:",
"no_data_available": "No hi ha dades disponibles",
"index": "Índex:",
"continue_watching": "Continuar veient",
"go_back": "Enrere"
},
"item_card": {
"next_up": "A continuació",
"no_items_to_display": "No hi ha elements per mostrar",
"cast_and_crew": "Repartiment i equip",
"series": "Sèries",
"seasons": "Temporades",
"season": "Temporada",
"no_episodes_for_this_season": "No hi ha episodis per a aquesta temporada",
"overview": "Descripció general",
"more_with": "Més amb {{name}}",
"similar_items": "Elements similars",
"no_similar_items_found": "No s'han trobat elements similars",
"video": "Vídeo",
"more_details": "Més detalls",
"quality": "Qualitat",
"audio": "Àudio",
"subtitles": "Subtítols",
"show_more": "Mostra més",
"show_less": "Mostra menys",
"appeared_in": "Va aparèixer a",
"could_not_load_item": "No s'ha pogut carregar l'element",
"none": "Cap",
"download": {
"download_season": "Descarrega la temporada",
"download_series": "Descarrega la sèrie",
"download_episode": "Descarrega l'episodi",
"download_movie": "Descarrega la pel·lícula",
"download_x_item": "Descarrega {{item_count}} elements",
"download_button": "Descarrega",
"using_optimized_server": "Utilitzant servidor optimitzat",
"using_default_method": "Utilitzant mètode per defecte"
}
},
"live_tv": {
"next": "Següent",
"previous": "Anterior",
"live_tv": "TV en directe",
"coming_soon": "Pròximament",
"on_now": "Ara en emissió",
"shows": "Programes",
"movies": "Pel·lícules",
"sports": "Esports",
"for_kids": "Infantil",
"news": "Notícies"
},
"jellyseerr": {
"confirm": "Confirma",
"cancel": "Cancel·la",
"yes": "Sí",
"whats_wrong": "Què està passant?",
"issue_type": "Tipus d'incidència",
"select_an_issue": "Seleccioneu una incidència",
"types": "Tipus",
"describe_the_issue": "(opcional) Descriviu la incidència...",
"submit_button": "Envia",
"report_issue_button": "Informa d'una incidència",
"request_button": "Sol·licita",
"are_you_sure_you_want_to_request_all_seasons": "Esteu segur que voleu sol·licitar totes les temporades?",
"failed_to_login": "No s'ha pogut iniciar sessió",
"cast": "Repartiment",
"details": "Detalls",
"status": "Estat",
"original_title": "Títol original",
"series_type": "Tipus de sèrie",
"release_dates": "Dates d'estrena",
"first_air_date": "Primera data d'emissió",
"next_air_date": "Propera data d'emissió",
"revenue": "Ingressos",
"budget": "Pressupost",
"original_language": "Idioma original",
"production_country": "País de producció",
"studios": "Estudis",
"network": "Cadena",
"currently_streaming_on": "Actualment en reproducció a",
"advanced": "Avançat",
"request_as": "Sol·licita com a",
"tags": "Etiquetes",
"quality_profile": "Perfil de qualitat",
"root_folder": "Carpeta arrel",
"season_all": "Temporada (totes)",
"season_number": "Temporada {{season_number}}",
"number_episodes": "{{episode_number}} episodis",
"born": "Nascut",
"appearances": "Aparicions",
"toasts": {
"jellyseer_does_not_meet_requirements": "El servidor Jellyseerr no compleix els requisits mínims de versió! Actualitzeu-lo almenys a la versió 2.0.0",
"jellyseerr_test_failed": "Ha fallat la prova de Jellyseerr. Torneu-ho a provar.",
"failed_to_test_jellyseerr_server_url": "No s'ha pogut provar l'URL del servidor de Jellyseerr",
"issue_submitted": "Incidència enviada!",
"requested_item": "S'ha sol·licitat {{item}}!",
"you_dont_have_permission_to_request": "No teniu permís per sol·licitar!",
"something_went_wrong_requesting_media": "Alguna cosa ha anat malament en sol·licitar contingut!"
}
},
"tabs": {
"home": "Inici",
"search": "Cercar",
"library": "Biblioteca",
"custom_links": "Enllaços personalitzats",
"favorites": "Preferits"
}
}

484
translations/vi.json Normal file
View File

@@ -0,0 +1,484 @@
{
"login": {
"username_required": "Cần nhập tên người dùng",
"error_title": "Lỗi",
"login_title": "Đăng nhập",
"login_to_title": "Đăng nhập vào",
"username_placeholder": "Tên người dùng",
"password_placeholder": "Mật khẩu",
"login_button": "Đăng nhập",
"quick_connect": "Kết nối nhanh",
"enter_code_to_login": "Nhập mã {{code}} để đăng nhập",
"failed_to_initiate_quick_connect": "Không thể bắt đầu Kết nối nhanh",
"got_it": "Đã hiểu",
"connection_failed": "Kết nối thất bại",
"could_not_connect_to_server": "Không thể kết nối tới máy chủ. Vui lòng kiểm tra URL và kết nối mạng.",
"an_unexpected_error_occured": "Đã xảy ra lỗi không mong muốn",
"change_server": "Đổi máy chủ",
"invalid_username_or_password": "Tên đăng nhập hoặc mật khẩu không đúng",
"user_does_not_have_permission_to_log_in": "Người dùng không có quyền đăng nhập",
"server_is_taking_too_long_to_respond_try_again_later": "Máy chủ phản hồi quá lâu, vui lòng thử lại sau",
"server_received_too_many_requests_try_again_later": "Máy chủ nhận quá nhiều yêu cầu, vui lòng thử lại sau.",
"there_is_a_server_error": "Đã xảy ra lỗi ở máy chủ",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Đã xảy ra lỗi không mong muốn. Bạn có chắc đã nhập đúng URL máy chủ?"
},
"server": {
"enter_url_to_jellyfin_server": "Nhập URL máy chủ Jellyfin của bạn",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Kết nối",
"previous_servers": "Máy chủ trước đó",
"clear_button": "Xóa",
"search_for_local_servers": "Tìm máy chủ trong mạng",
"searching": "Đang tìm...",
"servers": "Máy chủ"
},
"home": {
"no_internet": "Không có Internet",
"no_items": "Không có nội dung",
"no_internet_message": "Không sao, bạn vẫn có thể xem được nội dung đã tải xuống.",
"go_to_downloads": "Tới phần tải về",
"oops": "Ối!",
"error_message": "Có lỗi xảy ra.\nVui lòng đăng xuất rồi đăng nhập lại.",
"continue_watching": "Tiếp tục xem",
"next_up": "Tiếp theo",
"recently_added_in": "Mới thêm trong {{libraryName}}",
"suggested_movies": "Phim gợi ý",
"suggested_episodes": "Tập gợi ý",
"intro": {
"welcome_to_streamyfin": "Chào mừng đến với Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "Một ứng dụng miễn phí và mã nguồn mở cho Jellyfin.",
"features_title": "Tính năng",
"features_description": "Streamyfin có nhiều tính năng và tích hợp với nhiều phần mềm, xem trong mục cài đặt, bao gồm:",
"jellyseerr_feature_description": "Kết nối với Jellyseerr và yêu cầu phim ngay trong ứng dụng.",
"downloads_feature_title": "Tải xuống",
"downloads_feature_description": "Tải phim và chương trình để xem ngoại tuyến. Có thể dùng phương pháp mặc định hoặc cài đặt máy chủ tối ưu để hỗ trợ tải trong nền.",
"chromecast_feature_description": "Phát phim lên Chromecast.",
"centralised_settings_plugin_title": "Plugin cấu hình tập trung",
"centralised_settings_plugin_description": "Cài đặt đồng bộ hóa từ máy chủ Jellyfin. Tất cả cài đặt người dùng sẽ được đồng bộ.",
"done_button": "Xong",
"go_to_settings_button": "Tới cài đặt",
"read_more": "Xem thêm"
},
"settings": {
"settings_title": "Cài đặt",
"log_out_button": "Đăng xuất",
"user_info": {
"user_info_title": "Thông tin người dùng",
"user": "Người dùng",
"server": "Máy chủ",
"token": "Token",
"app_version": "Phiên bản ứng dụng"
},
"quick_connect": {
"quick_connect_title": "Kết nối nhanh",
"authorize_button": "Cho phép Kết nối nhanh",
"enter_the_quick_connect_code": "Nhập mã kết nối nhanh...",
"success": "Thành công",
"quick_connect_autorized": "Kết nối nhanh đã được cho phép",
"error": "Lỗi",
"invalid_code": "Mã không hợp lệ",
"authorize": "Cho phép"
},
"media_controls": {
"media_controls_title": "Điều khiển đa phương tiện",
"forward_skip_length": "Thời gian tua tới",
"rewind_length": "Thời gian tua lui",
"seconds_unit": "s"
},
"audio": {
"audio_title": "Âm thanh",
"set_audio_track": "Chọn track âm thanh từ mục trước",
"audio_language": "Ngôn ngữ âm thanh",
"audio_hint": "Chọn ngôn ngữ âm thanh mặc định.",
"none": "Không có",
"language": "Ngôn ngữ"
},
"subtitles": {
"subtitle_title": "Phụ đề",
"subtitle_language": "Ngôn ngữ phụ đề",
"subtitle_mode": "Chế độ phụ đề",
"set_subtitle_track": "Chọn phụ đề từ mục trước",
"subtitle_size": "Cỡ chữ",
"subtitle_hint": "Cấu hình tùy chọn phụ đề.",
"none": "Không có",
"language": "Ngôn ngữ",
"loading": "Đang tải",
"modes": {
"Default": "Mặc định",
"Smart": "Thông minh",
"Always": "Luôn hiện",
"None": "Không hiển thị",
"OnlyForced": "Bắt buộc"
}
},
"other": {
"other_title": "Khác",
"follow_device_orientation": "Tự xoay màn hình",
"video_orientation": "Hướng video",
"orientation": "Hướng",
"orientations": {
"DEFAULT": "Mặc định",
"ALL": "Tất cả",
"PORTRAIT": "Chân dung",
"PORTRAIT_UP": "Chân dung hướng lên",
"PORTRAIT_DOWN": "Chân dung hướng xuống",
"LANDSCAPE": "Ngang",
"LANDSCAPE_LEFT": "Ngang trái",
"LANDSCAPE_RIGHT": "Ngang phải",
"OTHER": "Khác",
"UNKNOWN": "Không rõ"
},
"safe_area_in_controls": "Vùng an toàn trong điều khiển",
"video_player": "Trình phát video",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Thử nghiệm + PiP)"
},
"show_custom_menu_links": "Hiện liên kết tùy chỉnh",
"hide_libraries": "Ẩn thư viện",
"select_liraries_you_want_to_hide": "Chọn các thư viện muốn ẩn khỏi mục Thư viện và Trang chủ.",
"disable_haptic_feedback": "Tắt phản hồi rung",
"default_quality": "Chất lượng mặc định",
"max_auto_play_episode_count": "Số tập tự chạy tối đa",
"disabled": "Đã tắt"
},
"downloads": {
"downloads_title": "Tải xuống",
"download_method": "Phương pháp tải",
"remux_max_download": "Giới hạn tải Remux tối đa",
"auto_download": "Tự động tải",
"optimized_versions_server": "Máy chủ phiên bản tối ưu",
"save_button": "Lưu",
"optimized_server": "Máy chủ tối ưu",
"optimized": "Tối ưu",
"default": "Mặc định",
"optimized_version_hint": "Nhập URL máy chủ tối ưu. Phải có http hoặc https và cổng nếu cần.",
"read_more_about_optimized_server": "Tìm hiểu thêm về máy chủ tối ưu.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "Plugin",
"jellyseerr": {
"jellyseerr_warning": "Tích hợp đang trong giai đoạn thử nghiệm. Nội dung có thể thay đổi.",
"server_url": "URL máy chủ",
"server_url_hint": "Ví dụ: http(s)://your-host.url (có port nếu cần)",
"server_url_placeholder": "Jellyseerr URL...",
"password": "Mật khẩu",
"password_placeholder": "Nhập mật khẩu cho người dùng {{username}} Jellyfin",
"save_button": "Lưu",
"clear_button": "Xóa",
"login_button": "Đăng nhập",
"total_media_requests": "Tổng số lượt yêu cầu nội dung",
"movie_quota_limit": "Giới hạn phim",
"movie_quota_days": "Số ngày giới hạn yêu cầu phim",
"tv_quota_limit": "Giới hạn TV",
"tv_quota_days": "Số ngày giới hạn yêu cầu TV",
"reset_jellyseerr_config_button": "Đặt lại cấu hình Jellyseerr",
"unlimited": "Không giới hạn",
"plus_n_more": "+{{n}} thêm",
"order_by": {
"DEFAULT": "Mặc định",
"VOTE_COUNT_AND_AVERAGE": "Dựa trên số lượt và điểm đánh giá",
"POPULARITY": "Theo độ phổ biến"
}
},
"marlin_search": {
"enable_marlin_search": "Bật Marlin Search",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Nhập URL máy chủ Marlin. Phải có http/https và port nếu cần.",
"read_more_about_marlin": "Tìm hiểu thêm về Marlin.",
"save_button": "Lưu",
"toasts": {
"saved": "Đã lưu"
}
}
},
"storage": {
"storage_title": "Lưu trữ",
"app_usage": "Ứng dụng sử dụng {{usedSpace}}%",
"device_usage": "Thiết bị sử dụng {{availableSpace}}%",
"size_used": "{{used}} / {{total}} đã dùng",
"delete_all_downloaded_files": "Xóa toàn bộ tập tin đã tải"
},
"intro": {
"show_intro": "Hiện giới thiệu",
"reset_intro": "Đặt lại giới thiệu"
},
"logs": {
"logs_title": "Nhật ký",
"export_logs": "Xuất nhật ký",
"click_for_more_info": "Nhấn để xem thêm thông tin",
"level": "Mức độ",
"no_logs_available": "Không có nhật ký",
"delete_all_logs": "Xóa tất cả nhật ký"
},
"languages": {
"title": "Ngôn ngữ",
"app_language": "Ngôn ngữ ứng dụng",
"app_language_description": "Chọn ngôn ngữ cho ứng dụng.",
"system": "Hệ thống"
},
"toasts": {
"error_deleting_files": "Lỗi khi xóa tập tin",
"background_downloads_enabled": "Tải trong nền đã bật",
"background_downloads_disabled": "Tải trong nền đã tắt",
"connected": "Đã kết nối",
"could_not_connect": "Không thể kết nối",
"invalid_url": "URL không hợp lệ"
}
},
"sessions": {
"title": "Phiên hoạt động",
"no_active_sessions": "Không có phiên đang hoạt động"
},
"downloads": {
"downloads_title": "Tải xuống",
"tvseries": "Chương trình TV",
"movies": "Phim",
"queue": "Hàng đợi",
"queue_hint": "Hàng đợi và tải xuống sẽ bị mất khi khởi động lại ứng dụng",
"no_items_in_queue": "Không có mục trong hàng đợi",
"no_downloaded_items": "Không có mục đã tải",
"delete_all_movies_button": "Xóa tất cả phim",
"delete_all_tvseries_button": "Xóa tất cả chương trình TV",
"delete_all_button": "Xóa tất cả",
"active_download": "Đang tải xuống",
"no_active_downloads": "Không có tải xuống đang diễn ra",
"active_downloads": "Đang tải xuống",
"new_app_version_requires_re_download": "Phiên bản ứng dụng mới yêu cầu tải lại",
"new_app_version_requires_re_download_description": "Cập nhật mới yêu cầu phải tải lại nội dung. Vui lòng xóa toàn bộ nội dung đã tải và thử lại.",
"back": "Quay lại",
"delete": "Xóa",
"something_went_wrong": "Đã xảy ra lỗi",
"could_not_get_stream_url_from_jellyfin": "Không thể lấy URL phát trực tiếp từ Jellyfin",
"eta": "Thời gian còn lại {{eta}}",
"methods": "Phương pháp",
"toasts": {
"you_are_not_allowed_to_download_files": "Bạn không có quyền tải nội dung.",
"deleted_all_movies_successfully": "Đã xóa tất cả phim thành công!",
"failed_to_delete_all_movies": "Xóa phim thất bại",
"deleted_all_tvseries_successfully": "Đã xóa tất cả chương trình TV thành công!",
"failed_to_delete_all_tvseries": "Xóa chương trình TV thất bại",
"download_cancelled": "Tải xuống bị hủy",
"could_not_cancel_download": "Không thể hủy tải xuống",
"download_completed": "Tải xuống hoàn tất",
"download_started_for": "Bắt đầu tải {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} đã sẵn sàng để tải",
"download_stated_for_item": "Bắt đầu tải {{item}}",
"download_failed_for_item": "Tải {{item}} thất bại {{error}}",
"download_completed_for_item": "Tải xong {{item}}",
"queued_item_for_optimization": "Đã đưa {{item}} vào hàng đợi tối ưu hóa",
"failed_to_start_download_for_item": "Không thể bắt đầu tải {{item}}: {{message}}",
"server_responded_with_status_code": "Máy chủ phản hồi mã {{statusCode}}",
"no_response_received_from_server": "Không nhận được phản hồi từ máy chủ",
"error_setting_up_the_request": "Lỗi khi thiết lập yêu cầu",
"failed_to_start_download_for_item_unexpected_error": "Không thể bắt đầu tải {{item}}: Lỗi không mong muốn",
"all_files_folders_and_jobs_deleted_successfully": "Đã xóa thành công tất cả tập tin, thư mục và công việc",
"an_error_occured_while_deleting_files_and_jobs": "Đã xảy ra lỗi khi xóa tập tin và công việc",
"go_to_downloads": "Tới phần tải về"
}
}
},
"search": {
"search_here": "Tìm tại đây...",
"search": "Tìm...",
"x_items": "{{count}} mục",
"library": "Thư viện",
"discover": "Khám phá",
"no_results": "Không có kết quả",
"no_results_found_for": "Không tìm thấy kết quả cho",
"movies": "Phim",
"series": "Chương trình",
"episodes": "Tập",
"collections": "Bộ sưu tập",
"actors": "Diễn viên",
"request_movies": "Yêu cầu phim",
"request_series": "Yêu cầu chương trình",
"recently_added": "Mới thêm",
"recent_requests": "Yêu cầu gần đây",
"plex_watchlist": "Danh sách xem Plex",
"trending": "Thịnh hành",
"popular_movies": "Phim phổ biến",
"movie_genres": "Thể loại phim",
"upcoming_movies": "Phim sắp chiếu",
"studios": "Hãng phim",
"popular_tv": "TV phổ biến",
"tv_genres": "Thể loại TV",
"upcoming_tv": "TV sắp chiếu",
"networks": "Mạng phát",
"tmdb_movie_keyword": "Từ khóa phim TMDB",
"tmdb_movie_genre": "Thể loại phim TMDB",
"tmdb_tv_keyword": "Từ khóa TV TMDB",
"tmdb_tv_genre": "Thể loại TV TMDB",
"tmdb_search": "Tìm TMDB",
"tmdb_studio": "Hãng phim TMDB",
"tmdb_network": "Mạng TMDB",
"tmdb_movie_streaming_services": "Dịch vụ streaming phim TMDB",
"tmdb_tv_streaming_services": "Dịch vụ streaming TV TMDB"
},
"library": {
"no_items_found": "Không tìm thấy nội dung",
"no_results": "Không có kết quả",
"no_libraries_found": "Không tìm thấy thư viện",
"item_types": {
"movies": "phim",
"series": "chương trình",
"boxsets": "bộ sưu tập",
"items": "nội dung"
},
"options": {
"display": "Hiển thị",
"row": "Hàng ngang",
"list": "Danh sách",
"image_style": "Kiểu hình ảnh",
"poster": "Ảnh bìa dọc",
"cover": "Bìa",
"show_titles": "Hiển thị tiêu đề",
"show_stats": "Hiện thống kê"
},
"filters": {
"genres": "Thể loại",
"years": "Năm",
"sort_by": "Sắp xếp theo",
"sort_order": "Thứ tự",
"asc": "Tăng dần",
"desc": "Giảm dần",
"tags": "Thẻ"
}
},
"favorites": {
"series": "Chương trình",
"movies": "Phim",
"episodes": "Tập",
"videos": "Video",
"boxsets": "Bộ sưu tập",
"playlists": "Danh sách phát",
"noDataTitle": "Chưa có mục yêu thích",
"noData": "Đánh dấu mục yêu thích để xem ở đây nhanh hơn."
},
"custom_links": {
"no_links": "Chưa có liên kết"
},
"player": {
"error": "Lỗi",
"failed_to_get_stream_url": "Không thể lấy URL phát trực tiếp",
"an_error_occured_while_playing_the_video": "Có lỗi khi phát video. Xem nhật ký trong cài đặt.",
"client_error": "Lỗi phía máy khách",
"could_not_create_stream_for_chromecast": "Không thể tạo luồng cho Chromecast",
"message_from_server": "Thông báo từ máy chủ: {{message}}",
"video_has_finished_playing": "Video đã phát xong!",
"no_video_source": "Không có nguồn video...",
"next_episode": "Tập tiếp theo",
"refresh_tracks": "Làm mới các track",
"subtitle_tracks": "Track phụ đề:",
"audio_tracks": "Track âm thanh:",
"playback_state": "Trạng thái phát:",
"no_data_available": "Không có dữ liệu",
"index": "Chỉ mục:",
"continue_watching": "Tiếp tục xem",
"go_back": "Quay lại"
},
"item_card": {
"next_up": "Tiếp theo",
"no_items_to_display": "Không có nội dung để hiển thị",
"cast_and_crew": "Diễn viên & Đội ngũ",
"series": "Chương trình",
"seasons": "Mùa",
"season": "Mùa",
"no_episodes_for_this_season": "Không có tập cho mùa này",
"overview": "Giới thiệu",
"more_with": "Thêm với {{name}}",
"similar_items": "Nội dung tương tự",
"no_similar_items_found": "Không tìm thấy nội dung tương tự",
"video": "Video",
"more_details": "Xem thêm chi tiết",
"quality": "Chất lượng",
"audio": "Âm thanh",
"subtitles": "Phụ đề",
"show_more": "Xem thêm",
"show_less": "Thu gọn",
"appeared_in": "Xuất hiện trong",
"could_not_load_item": "Không thể tải nội dung",
"none": "Không có",
"download": {
"download_season": "Tải mùa",
"download_series": "Tải chương trình",
"download_episode": "Tải tập",
"download_movie": "Tải phim",
"download_x_item": "Tải {{item_count}} nội dung",
"download_button": "Tải",
"using_optimized_server": "Đang dùng máy chủ tối ưu",
"using_default_method": "Đang dùng phương pháp mặc định"
}
},
"live_tv": {
"next": "Tiếp theo",
"previous": "Trước đó",
"live_tv": "TV trực tiếp",
"coming_soon": "Sắp chiếu",
"on_now": "Đang phát",
"shows": "Chương trình",
"movies": "Phim",
"sports": "Thể thao",
"for_kids": "Dành cho trẻ em",
"news": "Tin tức"
},
"jellyseerr": {
"confirm": "Xác nhận",
"cancel": "Hủy",
"yes": "Có",
"whats_wrong": "Có vấn đề gì?",
"issue_type": "Loại sự cố",
"select_an_issue": "Chọn sự cố",
"types": "Loại",
"describe_the_issue": "(tuỳ chọn) Mô tả sự cố...",
"submit_button": "Gửi",
"report_issue_button": "Báo lỗi",
"request_button": "Yêu cầu",
"are_you_sure_you_want_to_request_all_seasons": "Chắc chắn muốn yêu cầu tất cả các mùa?",
"failed_to_login": "Đăng nhập thất bại",
"cast": "Diễn viên",
"details": "Chi tiết",
"status": "Trạng thái",
"original_title": "Tiêu đề gốc",
"series_type": "Loại chương trình",
"release_dates": "Ngày phát hành",
"first_air_date": "Phát sóng lần đầu",
"next_air_date": "Phát sóng tiếp theo",
"revenue": "Doanh thu",
"budget": "Ngân sách",
"original_language": "Ngôn ngữ gốc",
"production_country": "Quốc gia sản xuất",
"studios": "Hãng sản xuất",
"network": "Đài phát sóng",
"currently_streaming_on": "Đang phát trên",
"advanced": "Nâng cao",
"request_as": "Yêu cầu dưới tên",
"tags": "Thẻ",
"quality_profile": "Hồ sơ chất lượng",
"root_folder": "Thư mục gốc",
"season_all": "Toàn bộ mùa",
"season_number": "Mùa {{season_number}}",
"number_episodes": "{{episode_number}} tập",
"born": "Ngày sinh",
"appearances": "Lần xuất hiện",
"toasts": {
"jellyseer_does_not_meet_requirements": "Máy chủ Jellyseerr không đạt yêu cầu tối thiểu! Vui lòng cập nhật lên ít nhất 2.0.0",
"jellyseerr_test_failed": "Kiểm tra Jellyseerr thất bại. Vui lòng thử lại.",
"failed_to_test_jellyseerr_server_url": "Không thể kiểm tra URL Jellyseerr",
"issue_submitted": "Đã gửi báo lỗi!",
"requested_item": "Đã yêu cầu {{item}}!",
"you_dont_have_permission_to_request": "Bạn không có quyền để yêu cầu!",
"something_went_wrong_requesting_media": "Có lỗi khi thực hiện yêu cầu!"
}
},
"tabs": {
"home": "Trang chính",
"search": "Tìm kiếm",
"library": "Thư viện",
"custom_links": "Liên kết tùy chỉnh",
"favorites": "Yêu thích"
}
}

View File

@@ -139,6 +139,7 @@
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
"disable_haptic_feedback": "禁用觸覺回饋",
"default_quality": "預設品質",
"max_auto_play_episode_count": "自動播放劇集的最大次數",
"disabled": "已停用"
},
"downloads": {
@@ -228,6 +229,10 @@
"invalid_url": "無效的 URL"
}
},
"sessions": {
"title": "會話",
"no_active_sessions": "沒有使用中的會話"
},
"downloads": {
"downloads_title": "下載",
"tvseries": "電視劇",

View File

@@ -237,25 +237,38 @@ const loadSettings = (): Partial<Settings> => {
return loadedValues;
} catch (error) {
console.error("Failed to load settings:", error);
return defaultValues;
return {};
}
};
const EXCLUDE_FROM_SAVE = ["home"];
const saveSettings = (settings: Settings) => {
for (const key of Object.keys(settings)) {
if (EXCLUDE_FROM_SAVE.includes(key)) {
delete settings[key as keyof Settings];
try {
for (const key of Object.keys(settings)) {
if (EXCLUDE_FROM_SAVE.includes(key)) {
delete settings[key as keyof Settings];
}
}
const jsonValue = JSON.stringify(settings);
storage.set("settings", jsonValue);
} catch (error) {
console.error("Failed to save settings:", error);
}
const jsonValue = JSON.stringify(settings);
storage.set("settings", jsonValue);
};
export const settingsAtom = atom<Partial<Settings> | null>(null);
export const pluginSettingsAtom = atom(
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS),
const loadPluginSettings = () => {
try {
return storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS);
} catch (error) {
console.error("Failed to load plugin settings:", error);
return undefined;
}
};
export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(),
);
export const useSettings = () => {
@@ -317,7 +330,7 @@ export const useSettings = () => {
// If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required.
const settings: Settings = useMemo(() => {
const unlockedPluginDefaults = {} as Settings;
const unlockedPluginDefaults: Partial<Settings> = {};
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial<Settings>
>((acc, [key, setting]) => {
@@ -331,14 +344,12 @@ export const useSettings = () => {
value !== undefined &&
_settings?.[settingsKey] !== value
) {
Object.assign(unlockedPluginDefaults, {
[settingsKey]: value,
});
(unlockedPluginDefaults as any)[settingsKey] = value;
}
Object.assign(acc, {
[settingsKey]: locked ? value : (_settings?.[settingsKey] ?? value),
});
(acc as any)[settingsKey] = locked
? value
: (_settings?.[settingsKey] ?? value);
}
return acc;
}, {});

View File

@@ -23,7 +23,6 @@ export const reportPlaybackProgress = async ({
itemId,
positionTicks,
IsPaused = false,
deviceProfile,
}: ReportPlaybackProgressParams): Promise<void> => {
if (!api || !sessionId || !itemId || !positionTicks) {
return;

View File

@@ -1,3 +1,5 @@
import { MMKV } from "react-native-mmkv";
// Create a single MMKV instance following the official documentation
// https://github.com/mrousavy/react-native-mmkv
export const storage = new MMKV();

View File

@@ -3,7 +3,7 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import axios from "axios";
import { MMKV } from "react-native-mmkv";
import { storage } from "@/utils/mmkv";
import { writeToLog } from "./log";
interface IJobInput {
@@ -173,8 +173,6 @@ export function saveDownloadItemInfoToDiskTmp(
url: string,
): boolean {
try {
const storage = new MMKV();
const downloadInfo = JSON.stringify({
item,
mediaSource,
@@ -206,7 +204,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
url: string;
} | null {
try {
const storage = new MMKV();
const rawInfo = storage.getString(`tmp_download_info_${itemId}`);
if (rawInfo) {
@@ -227,7 +224,6 @@ export function getDownloadItemInfoFromDiskTmp(itemId: string): {
*/
export function deleteDownloadItemInfoFromDiskTmp(itemId: string): boolean {
try {
const storage = new MMKV();
storage.delete(`tmp_download_info_${itemId}`);
return true;
} catch (error) {