Compare commits

..

15 Commits

Author SHA1 Message Date
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 2508 additions and 1843 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

@@ -190,6 +190,7 @@ export default function page() {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
if (!item) return;
const res = await getStreamUrl({
api,
item,
@@ -516,7 +517,18 @@ export default function page() {
return () => setIsMounted(false);
}, []);
if (itemStatus.isLoading || streamStatus.isLoading) {
// Show error UI first, before checking loading/missingdata
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>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader />
@@ -573,7 +585,7 @@ export default function page() {
}}
/>
</View>
{videoRef.current && !isPipStarted && isMounted === true && item ? (
{!isPipStarted && isMounted === true && item && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -589,7 +601,7 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={videoRef?.current?.startPictureInPicture}
startPictureInPicture={videoRef.current?.startPictureInPicture}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
@@ -597,12 +609,12 @@ export default function page() {
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
setSubtitleTrack={videoRef.current?.setSubtitleTrack}
setSubtitleURL={videoRef.current?.setSubtitleURL}
setAudioTrack={videoRef.current?.setAudioTrack}
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.3/schema.json",
"files": {
"includes": [
"**/*",

1637
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.3",
"@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",
"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) {