Merge branch 'develop' into fix/vlc4
14
.claude/settings.local.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal 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.
|
||||
42
.github/workflows/build-android.yml
vendored
@@ -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
|
||||
|
||||
|
||||
38
.github/workflows/build-ios.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/check-lockfile.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
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@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
|
||||
|
||||
30
.github/workflows/linting.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/notification.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
|
||||
1
.gitignore
vendored
@@ -45,3 +45,4 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
|
||||
29
README.md
@@ -1,15 +1,24 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||
</div>
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
@@ -47,7 +56,7 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
26
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.29.1",
|
||||
"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": 56,
|
||||
"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": [
|
||||
@@ -117,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
|
||||
}
|
||||
],
|
||||
@@ -133,12 +133,8 @@
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
]
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +51,8 @@ export default function page() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
@@ -57,7 +61,11 @@ export default function page() {
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
||||
<Ionicons
|
||||
name='cloud-download-outline'
|
||||
size={32}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
@@ -85,6 +93,8 @@ export default function page() {
|
||||
</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>
|
||||
<View className='flex-row flex-wrap items-baseline'>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||
<Text
|
||||
className='text-purple-600'
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className='text-xs text-purple-600 underline'>
|
||||
{t("home.intro.read_more")}
|
||||
</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -434,8 +434,6 @@ const TranscodingStreamView = ({
|
||||
isTranscoding,
|
||||
properties,
|
||||
transcodeProperties,
|
||||
value,
|
||||
transcodeValue,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className='flex flex-col pt-2 first:pt-0'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +252,7 @@ const Page: React.FC = () => {
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
@@ -276,6 +273,7 @@ const Page: React.FC = () => {
|
||||
{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,6 +331,8 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
onDismiss={() => _setRequestBody(undefined)}
|
||||
/>
|
||||
{!Platform.isTV && (
|
||||
// This is till it's fixed because the menu isn't selectable on TV
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -419,6 +419,7 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type BottomTabNavigationEventMap,
|
||||
type BottomTabNavigationOptions,
|
||||
createBottomTabNavigator,
|
||||
} from "@react-navigation/bottom-tabs";
|
||||
createNativeBottomTabNavigator,
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
@@ -17,13 +17,13 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const { Navigator } = createBottomTabNavigator();
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
NativeBottomTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
BottomTabNavigationEventMap
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
export default function TabLayout() {
|
||||
@@ -51,7 +51,6 @@ export default function TabLayout() {
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
@@ -60,7 +59,7 @@ export default function TabLayout() {
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
@@ -70,8 +69,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
@@ -79,7 +77,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
@@ -89,8 +87,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
@@ -103,7 +100,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ color, focused, size }) =>
|
||||
? ({ focused }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
@@ -119,8 +116,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
@@ -131,11 +127,10 @@ export default function TabLayout() {
|
||||
name='(custom-links)'
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
// @ts-expect-error
|
||||
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" }
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
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 isn’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -146,12 +146,15 @@ if (!Platform.isTV) {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
@@ -229,8 +232,13 @@ if (!Platform.isTV) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 79 KiB |
BIN
assets/images/icon-android-plain.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/icon-android-themed.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 75 KiB |
@@ -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 {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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": [
|
||||
"**/*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "zeego/context-menu";
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
|
||||
};
|
||||
});
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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%"], []);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -40,7 +40,6 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,7 +82,6 @@ const ListItemContent = ({
|
||||
showArrow,
|
||||
iconAfter,
|
||||
children,
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,7 +38,6 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
horizontal,
|
||||
showDownloadInfo,
|
||||
mediaRequest,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||
const loadingOpacity = useSharedValue(1);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -57,7 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem = ({ item, index }: any) => {
|
||||
const RenderItem = ({ item }: any) => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,6 +831,7 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<BrightnessSlider />
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<View
|
||||
style={{
|
||||
@@ -697,7 +862,11 @@ export const Controls: FC<Props> = ({
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay();
|
||||
@@ -716,7 +885,9 @@ export const Controls: FC<Props> = ({
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
style={{
|
||||
@@ -740,7 +911,7 @@ export const Controls: FC<Props> = ({
|
||||
</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}
|
||||
/>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
6
eas.json
@@ -46,14 +46,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.1",
|
||||
"channel": "0.29.13",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.1",
|
||||
"channel": "0.29.13",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.29.1",
|
||||
"channel": "0.29.13",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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,22 +63,17 @@ 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;
|
||||
}) => {
|
||||
.then((colors: ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
@@ -89,11 +82,13 @@ export const useImageColors = ({
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
@@ -101,12 +96,12 @@ export const useImageColors = ({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
i18n.ts
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
154
package.json
@@ -19,123 +19,123 @@
|
||||
"format": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config-plugins": "~9.0.15",
|
||||
"@bottom-tabs/react-navigation": "^0.9.2",
|
||||
"@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",
|
||||
"@react-native-menu/menu": "^1.2.2",
|
||||
"@react-navigation/bottom-tabs": "^7.4.2",
|
||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||
"@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.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.26.17",
|
||||
"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.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "3.5.1",
|
||||
"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.10.0",
|
||||
"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",
|
||||
"sonner-native": "^0.17.0",
|
||||
"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": "^2.0.4",
|
||||
"zeego": "^3.0.6",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.8",
|
||||
"@biomejs/biome": "^2.1.2",
|
||||
"@react-native-community/cli": "18.0.0",
|
||||
"@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": "^7.0.3",
|
||||
"cross-env": "^10.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.2",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"typescript": "~5.7.3"
|
||||
"react-test-renderer": "19.1.1",
|
||||
"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"
|
||||
|
||||
66
plugins/with-runtime-framework-headers.js
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
/** @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)
|
||||
}
|
||||
|
||||
// Insert the delegate method above the @end statement
|
||||
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
|
||||
appDelegateLines.splice(
|
||||
endStatementIndex,
|
||||
0,
|
||||
backgroundDownloaderDelegate,
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 xcode‑js 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.29.2" },
|
||||
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.2"`,
|
||||
}, DeviceId="${deviceId}", Version="0.29.13"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
20
react-native.config.js
Normal 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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "電視劇",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
|
||||
81
update-version.sh
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# bump-version.sh — Update versions in eas.json, app.json, and JellyfinProvider.tsx
|
||||
# Usage: ./bump-version.sh 1.2.3
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- UI ----------
|
||||
BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m'
|
||||
BLUE=$'\033[34m'; GREEN=$'\033[32m'; RED=$'\033[31m'; MAG=$'\033[35m'
|
||||
|
||||
title() { printf "\n${BOLD}${MAG}▌ %s${RESET}\n" "$1"; }
|
||||
info() { printf "${DIM}› %s${RESET}\n" "$1"; }
|
||||
ok() { printf "${GREEN}✔ %s${RESET}\n" "$1"; }
|
||||
err() { printf "${RED}✖ %s${RESET}\n" "$1"; }
|
||||
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { err "Missing '$1'"; exit 1; }; }
|
||||
|
||||
# ---------- Args & checks ----------
|
||||
NEW="${1-}"
|
||||
[[ "$NEW" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { err "Provide semver like 1.2.3"; exit 1; }
|
||||
|
||||
need jq
|
||||
need sd
|
||||
need git
|
||||
|
||||
EAS="eas.json"
|
||||
APP="app.json"
|
||||
TS="providers/JellyfinProvider.tsx"
|
||||
for f in "$EAS" "$APP" "$TS"; do [[ -f "$f" ]] || { err "File not found: $f"; exit 1; }; done
|
||||
|
||||
OLD=$(jq -r '.expo.version' "$APP")
|
||||
OLDCODE=$(jq -r '.expo.android.versionCode // 0' "$APP")
|
||||
NEWCODE=$((OLDCODE + 1))
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
title "Preparing changes → ${BLUE}$NEW${RESET} (${DIM}android versionCode $OLDCODE → $NEWCODE${RESET})"
|
||||
|
||||
# ---------- Build proposed files ----------
|
||||
# app.json: bump version + android.versionCode
|
||||
jq --arg v "$NEW" --argjson nc "$NEWCODE" \
|
||||
'.expo.version=$v | .expo.android.versionCode=$nc' \
|
||||
"$APP" > "$TMPDIR/app.json"
|
||||
|
||||
# eas.json: set all build.*.channel if present
|
||||
jq --arg v "$NEW" '
|
||||
.build |= (with_entries(.value |= (if has("channel") then (.channel=$v) else . end)))
|
||||
' "$EAS" > "$TMPDIR/eas.json"
|
||||
|
||||
# JellyfinProvider.tsx: replace embedded version strings
|
||||
cp "$TS" "$TMPDIR/JellyfinProvider.tsx"
|
||||
sd 'version:\s*"[0-9]+\.[0-9]+\.[0-9]+"' "version: \"$NEW\"" "$TMPDIR/JellyfinProvider.tsx" >/dev/null
|
||||
sd 'Version="[0-9]+\.[0-9]+\.[0-9]+"' "Version=\"$NEW\"" "$TMPDIR/JellyfinProvider.tsx" >/dev/null
|
||||
|
||||
# ---------- Preview diffs ----------
|
||||
title "Preview (no files changed yet)"
|
||||
show_diff () {
|
||||
local a="$1" b="$2" name="$3"
|
||||
printf "\n${BOLD}%s${RESET}\n" "$name"
|
||||
git --no-pager diff --no-index --color --minimal "$a" "$b" || true
|
||||
}
|
||||
show_diff "$APP" "$TMPDIR/app.json" "$APP"
|
||||
show_diff "$EAS" "$TMPDIR/eas.json" "$EAS"
|
||||
show_diff "$TS" "$TMPDIR/JellyfinProvider.tsx" "$TS"
|
||||
|
||||
printf "\n${BOLD}Apply these changes?${RESET} [y/N] "
|
||||
read -r REPLY
|
||||
if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
|
||||
info "Aborted. No files modified."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- Apply ----------
|
||||
mv "$TMPDIR/app.json" "$APP"
|
||||
mv "$TMPDIR/eas.json" "$EAS"
|
||||
mv "$TMPDIR/JellyfinProvider.tsx" "$TS"
|
||||
|
||||
ok "Updated to version ${BOLD}$NEW${RESET}"
|
||||
ok "Android versionCode ${BOLD}$NEWCODE${RESET}"
|
||||
info "Files changed: $APP, $EAS, $TS"
|
||||
@@ -236,13 +236,14 @@ 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) => {
|
||||
try {
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||
delete settings[key as keyof Settings];
|
||||
@@ -250,11 +251,23 @@ const saveSettings = (settings: Settings) => {
|
||||
}
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
storage.set("settings", jsonValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
@@ -316,7 +329,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]) => {
|
||||
@@ -330,14 +343,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;
|
||||
}, {});
|
||||
|
||||
@@ -23,7 +23,6 @@ export const reportPlaybackProgress = async ({
|
||||
itemId,
|
||||
positionTicks,
|
||||
IsPaused = false,
|
||||
deviceProfile,
|
||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||
return;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||