Compare commits

..

16 Commits

Author SHA1 Message Date
Uruk
5e81576c68 feat(home): adds pull to refresh functionality
Adds pull-to-refresh functionality on the home screen.

This change enhances user experience by allowing users to
manually refresh the home screen content. It also ensures
that the loading indicator is correctly managed during
refresh operations, using a try-finally block.
2026-02-11 19:09:01 +01:00
Uruk
a16d73688f feat(home): refresh data on app foreground
adds a hook that refetches home data when the app
returns to the foreground or starts from a cold start.

this ensures the home screen always displays the
most up-to-date content.
2026-02-10 23:10:07 +01:00
Uruk
ac0d3fcb86 refactor(item): improves top people display logic
Improves the logic for displaying the top people in the item sections.

The previous implementation simply sliced the people array which
could result in duplicate people being displayed.

This change ensures that the top people displayed are unique by
filtering duplicates based on their id, limiting the number of
displayed people to three.
2026-02-10 19:13:44 +01:00
renovate[bot]
8a782fb0c9 chore(deps): Update dependency @react-native-community/cli to v20.1.1 (#1413)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:38 +01:00
renovate[bot]
7d0b6c37b8 chore(deps): Update dependency react-i18next to v16.5.4 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:01 +01:00
renovate[bot]
5443222e4e chore(deps): Update actions/cache action to v5.0.2 (#1397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:31 +01:00
renovate[bot]
a77e86be21 chore(deps): Update github/codeql-action action to v4.32.0 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:22 +01:00
renovate[bot]
9d82549cd6 chore(deps): Update dependency @tanstack/react-query to v5.90.20 (#1405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:12 +01:00
renovate[bot]
cb5cb8bff7 chore(deps): Update dependency lodash to v4.17.23 [SECURITY] (#1394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:02 +01:00
renovate[bot]
0b61fb7d98 chore(deps): Update peter-evans/create-pull-request action to v8.1.0 (#1401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:53 +01:00
renovate[bot]
f762b9017f chore(deps): Update crowdin/github-action action to v2.14.0 (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:43 +01:00
renovate[bot]
b063fd6405 chore(deps): Update oven-sh/setup-bun action to v2.1.2 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:35 +01:00
renovate[bot]
88ac94ebeb chore(deps): Update actions/checkout action to v6.0.2 (#1398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:24 +01:00
Fredrik Burmester
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-01-19 08:41:52 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
27 changed files with 1135 additions and 1679 deletions

View File

@@ -33,7 +33,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -41,12 +41,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -116,7 +116,7 @@ jobs:
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -124,12 +124,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -187,7 +187,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -195,12 +195,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -251,7 +251,7 @@ jobs:
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -259,12 +259,12 @@ jobs:
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.bun/install/cache

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0

View File

@@ -23,12 +23,12 @@ jobs:
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
with:
upload_sources: true
upload_translations: true

View File

@@ -51,7 +51,7 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
@@ -100,7 +100,7 @@ jobs:
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
@@ -112,7 +112,7 @@ jobs:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report

View File

@@ -41,15 +41,6 @@ export default function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="google-cast-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);

View File

@@ -449,7 +449,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent;
const { position, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
@@ -459,6 +459,12 @@ export default function page() {
progress.set(currentTime);
// Update cache progress (current position + buffered seconds ahead)
if (cacheSeconds !== undefined && cacheSeconds > 0) {
const cacheEnd = currentTime + cacheSeconds * 1000;
cacheProgress.set(cacheEnd);
}
// Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get();

View File

@@ -1,184 +0,0 @@
import React, { useMemo, useState, useRef } from "react";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Button } from "@/components/Button";
import { Feather } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import GoogleCast, {
CastButton,
CastContext,
CastState,
useCastDevice,
useCastState,
useDevices,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useRouter } from "expo-router";
import { useHaptic } from "@/hooks/useHaptic";
import ChromecastControls from "@/components/ChromecastControls";
import { useTranslation } from "react-i18next";
export default function Player() {
const castState = useCastState();
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
const reportPlaybackStopedRef = useRef(() => {});
useEffect(() => {
if (mediaStatus) return; // media currently playing
// media was just playing, report playback stopped
if (wasMediaPlaying) {
reportPlaybackStopedRef.current();
setWasMediaPlaying(false);
}
}, [mediaStatus, wasMediaPlaying]);
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const { t } = useTranslation();
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.warn("DiscoveryManager is not initialized");
return;
}
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const GoHomeButton = useCallback(
() => (
<Button
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
{t("chromecast.go_home")}
</Button>
),
[router]
);
const ChromecastControlsMemoized = useMemo(() => {
if (!mediaStatus || !client) return undefined;
return (
<ChromecastControls
mediaStatus={mediaStatus}
client={client}
setWasMediaPlaying={setWasMediaPlaying}
reportPlaybackStopedRef={reportPlaybackStopedRef}
/>
);
}, [mediaStatus, client]);
if (
castState === CastState.NO_DEVICES_AVAILABLE ||
castState === CastState.NOT_CONNECTED
) {
// no devices to connect to
if (devices.length === 0) {
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_devices_available")}
</Text>
<Text className="text-gray-400">
{t("chromecast.are_you_on_same_network")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
// no device selected
return (
<View className="w-screen h-screen flex flex-col ">
<AndroidCastButton />
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<RoundButton
size="large"
background={false}
onPress={() => {
lightHapticFeedback();
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={42} color={"white"} />
</RoundButton>
<Text className="text-white text-xl mt-2">
{t("chromecast.no_device_selected")}
</Text>
<Text className="text-gray-400">
{t("chromecast.click_icon_to_connect")}
</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
if (castState === CastState.CONNECTING) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white font-semibold lg mb-2">
{t("chromecast.establishing_connection")}
</Text>
<Loader />
</View>
);
}
// connected, but no media playing
if (!mediaStatus) {
return (
<View className="w-screen h-screen flex flex-col ">
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white text-lg">
{t("chromecast.no_media_selected")}
</Text>
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
</View>
<View className="px-10">
<GoHomeButton />
</View>
</View>
);
}
return ChromecastControlsMemoized;
}

View File

@@ -19,7 +19,7 @@
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9",
"expo": "~54.0.31",
@@ -52,12 +52,12 @@
"expo-web-browser": "~15.0.10",
"i18next": "^25.0.0",
"jotai": "2.16.2",
"lodash": "4.17.21",
"lodash": "4.17.23",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.3",
"react-i18next": "16.5.4",
"react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
@@ -97,7 +97,7 @@
"devDependencies": {
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",
@@ -512,29 +512,29 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
"@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
@@ -602,7 +602,7 @@
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
@@ -1376,7 +1376,7 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
@@ -1640,7 +1640,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
@@ -1854,6 +1854,8 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -2242,7 +2244,7 @@
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],

View File

@@ -1,8 +1,5 @@
import { Feather } from "@expo/vector-icons";
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import GoogleCast, {
@@ -13,9 +10,7 @@ import GoogleCast, {
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { RoundButton } from "./RoundButton";
import { useRouter } from "expo-router";
export function Chromecast({
width = 48,
@@ -29,12 +24,6 @@ export function Chromecast({
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const lastReportedProgressRef = useRef(0);
const router = useRouter();
useEffect(() => {
(async () => {
@@ -47,53 +36,6 @@ export function Chromecast({
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// Report video progress to Jellyfin server
useEffect(() => {
if (
!api ||
!user?.Id ||
!mediaStatus ||
!mediaStatus.mediaInfo?.contentId
) {
return;
}
const streamPosition = mediaStatus.streamPosition || 0;
// Report every 10 seconds
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
return;
}
const contentId = mediaStatus.mediaInfo.contentId;
const positionTicks = Math.floor(streamPosition * 10000000);
const isPaused = mediaStatus.playerState === "paused";
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
const isTranscoding = streamUrl.includes("m3u8");
const progressInfo: PlaybackProgressInfo = {
ItemId: contentId,
PositionTicks: positionTicks,
IsPaused: isPaused,
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
PlaySessionId: contentId,
};
getPlaystateApi(api)
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
.then(() => {
lastReportedProgressRef.current = streamPosition;
})
.catch((error) => {
console.error("Failed to report Chromecast progress:", error);
});
}, [
api,
user?.Id,
mediaStatus?.streamPosition,
mediaStatus?.mediaInfo?.contentId,
]);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
@@ -124,7 +66,7 @@ export function Chromecast({
className='mr-2'
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
@@ -138,7 +80,7 @@ export function Chromecast({
<RoundButton
size='large'
onPress={() => {
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}

View File

@@ -1,897 +0,0 @@
import React, { useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Feather, Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import {
CastButton,
CastContext,
MediaStatus,
RemoteMediaClient,
useStreamPosition,
} from "react-native-google-cast";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { Image } from "expo-image";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { debounce } from "lodash";
import { useSettings } from "@/utils/atoms/settings";
import { useHaptic } from "@/hooks/useHaptic";
import { writeToLog } from "@/utils/log";
import { formatTimeString } from "@/utils/time";
import SkipButton from "@/components/video-player/controls/SkipButton";
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useTrickplay } from "@/hooks/useTrickplay";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
import { useAtomValue } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
import { SelectedOptions } from "./ItemContent";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { useQuery } from "@tanstack/react-query";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useTranslation } from "react-i18next";
import { Colors } from "@/constants/Colors";
import { useRouter } from "expo-router";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { ItemImage } from "@/components/common/ItemImage";
import { BitrateSelector } from "@/components/BitrateSelector";
import { ItemHeader } from "@/components/ItemHeader";
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
import { OverviewText } from "@/components/OverviewText";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PlayedStatus } from "./PlayedStatus";
import { AddToFavorites } from "./AddToFavorites";
export default function ChromecastControls({
mediaStatus,
client,
setWasMediaPlaying,
reportPlaybackStopedRef,
}: {
mediaStatus: MediaStatus;
client: RemoteMediaClient;
setWasMediaPlaying: (wasPlaying: boolean) => void;
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
}) {
const lightHapticFeedback = useHaptic("light");
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(Infinity);
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
const streamPosition = useStreamPosition();
const progress = useSharedValue(streamPosition || 0);
const wasPlayingRef = useRef(false);
const isSeeking = useSharedValue(false);
const isPlaying = useMemo(
() => mediaStatus.playerState === "playing",
[mediaStatus.playerState]
);
const isBufferingOrLoading = useMemo(
() =>
mediaStatus.playerState === null ||
mediaStatus.playerState === "buffering" ||
mediaStatus.playerState === "loading",
[mediaStatus.playerState]
);
// request update of media status every player state change
useEffect(() => {
client.requestStatus();
}, [mediaStatus.playerState]);
// update max progress
useEffect(() => {
if (mediaStatus.mediaInfo?.streamDuration)
max.value = mediaStatus.mediaInfo?.streamDuration;
}, [mediaStatus.mediaInfo?.streamDuration]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
setCurrentTime(currentProgress);
setRemainingTime(maxValue - currentProgress);
},
[]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
const { mediaMetadata, itemId, streamURL } = useMemo(
() => ({
mediaMetadata: mediaStatus.mediaInfo?.metadata,
itemId: mediaStatus.mediaInfo?.contentId,
streamURL: mediaStatus.mediaInfo?.contentUrl,
}),
[mediaStatus]
);
const type = useMemo(
() => mediaMetadata?.type || "generic",
[mediaMetadata?.type]
);
const images = useMemo(
() => mediaMetadata?.images || [],
[mediaMetadata?.images]
);
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
const mediaCustomData = mediaStatus.mediaInfo?.customData as
| {
playbackOptions: SelectedOptions;
sessionId?: string;
mediaSourceId?: string;
}
| undefined;
return (
mediaCustomData || {
playbackOptions: undefined,
sessionId: undefined,
mediaSourceId: undefined,
}
);
}, [mediaStatus.mediaInfo?.customData]);
const {
data: item,
// currently nothing is indicating that item is loading, because most of the time it loads very fast
isLoading: isLoadingItem,
isError: isErrorItem,
error,
refetch,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!itemId) return;
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId,
staleTime: 0,
});
const onProgress = useCallback(
async (progressInTicks: number, isPlaying: boolean) => {
if (!item?.Id || !streamURL) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
positionTicks: Math.floor(progressInTicks),
isPaused: !isPlaying,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
},
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
);
// update progess on stream position change
useEffect(() => {
if (streamPosition) {
progress.value = streamPosition;
onProgress(secondsToTicks(streamPosition), isPlaying);
}
}, [streamPosition, isPlaying]);
const reportPlaybackStart = useCallback(async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: playbackOptions?.audioIndex,
subtitleStreamIndex: playbackOptions?.subtitleIndex,
mediaSourceId,
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: sessionId,
});
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
// report playback started
useEffect(() => {
setWasMediaPlaying(true);
reportPlaybackStart();
}, [reportPlaybackStart]);
// update the reportPlaybackStoppedRef
useEffect(() => {
reportPlaybackStopedRef.current = async () => {
if (!streamURL) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId,
positionTicks: secondsToTicks(progress.value),
playSessionId: sessionId,
});
};
}, [
api,
item,
playbackOptions,
progress,
mediaSourceId,
streamURL,
sessionId,
]);
const { previousItem, nextItem } = useAdjacentItems({
item: {
Id: itemId,
SeriesId: item?.SeriesId,
Type: item?.Type,
},
});
const goToItem = useCallback(
async (item: BaseItemDto) => {
if (!api) {
console.warn("Failed to go to item: No api!");
return;
}
const previousIndexes: previousIndexes = {
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
audioIndex: playbackOptions?.audioIndex || undefined,
};
const {
mediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: defaultAudioIndex,
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
subtitleStreamIndex: defaultSubtitleIndex,
mediaSourceId: mediaSource?.Id,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert("Client error", "Could not create stream for Chromecast");
return;
}
await chromecastLoadMedia({
client,
item,
contentUrl: data.url,
sessionId: data.sessionId || undefined,
mediaSourceId: data.mediaSource?.Id || undefined,
playbackOptions,
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
});
await client.requestStatus();
},
[client, api]
);
const goToNextItem = useCallback(() => {
if (!nextItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(nextItem);
}, [nextItem, lightHapticFeedback]);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
console.warn("Failed to skip to next item: No next item!");
return;
}
lightHapticFeedback();
goToItem(previousItem);
}, [previousItem, lightHapticFeedback]);
const pause = useCallback(() => {
client.pause();
}, [client]);
const play = useCallback(() => {
client.play();
}, [client]);
const seek = useCallback(
(time: number) => {
// skip to next episode if seeking to end (for credit skipping)
// with 1 second room to react
if (nextItem && time >= max.value - 1) {
goToNextItem();
return;
}
client.seek({
position: time,
});
},
[client, goToNextItem, nextItem, max]
);
const togglePlay = useCallback(() => {
if (isPlaying) pause();
else play();
}, [isPlaying, play, pause]);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = Math.max(0, curr - settings.rewindSkipTime);
seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = curr + settings.forwardSkipTime;
seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const { showSkipButton, skipIntro } = useIntroSkipper(
itemId,
currentTime,
seek,
play,
false
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
itemId,
currentTime,
seek,
play,
false
);
// Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback(
() =>
Platform.OS === "android" ? (
<CastButton tintColor="transparent" />
) : (
<></>
),
[Platform.OS]
);
const TrickplaySliderMemoized = useMemo(
() => (
<TrickplaySlider
item={item}
progress={progress}
wasPlayingRef={wasPlayingRef}
isPlaying={isPlaying}
isSeeking={isSeeking}
range={{ max }}
play={play}
pause={pause}
seek={seek}
/>
),
[
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
max,
play,
pause,
seek,
]
);
const NextEpisodeButtonMemoized = useMemo(
() => (
<NextEpisodeCountDownButton
show={nextItem !== null && max.value > 0 && remainingTime < 10}
onFinish={goToNextItem}
onPress={goToNextItem}
/>
),
[nextItem, max, remainingTime, goToNextItem]
);
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
const logoUrl = useMemo(() => images[0]?.url, [images]);
if (isErrorItem) {
return (
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
<View className="p-12 flex gap-4">
<Text className="text-center font-semibold text-red-500 text-lg">
{t("chromecast.error_loading_item")}
</Text>
{error && (
<Text className="text-center opacity-80">{error.message}</Text>
)}
</View>
<View className="flex gap-2 mt-auto mb-20">
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => refetch()}
>
<Ionicons name="reload" size={24} color={Colors.primary} />
<Text className="ml-2 text-purple-600 text-lg">
{t("chromecast.retry_load_item")}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex flex-row items-center justify-center gap-2"
onPress={() => {
router.push("/(auth)/(home)/");
}}
>
<Ionicons name="home" size={16} color={Colors.text} />
<Text className="ml-2 text-white text-sm underline">
{t("chromecast.go_home")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
if (!item) {
return <Text>Do something when item is undefined</Text>;
}
if (!playbackOptions) {
return <Text>Do something when playbackOptions is undefined</Text>;
}
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{/* TODO do navigation header properly */}
<View
className="flex flex-row justify-between absolute w-full top-2 z-50"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<RoundButton size="large" icon="arrow-back" />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<RoundButton
size="large"
onPress={() => {
CastContext.showCastDialog();
}}
>
<AndroidCastButton />
<Feather name="cast" size={24} color={"white"} />
</RoundButton>
<PlayedStatus items={[item]} size="large" />
<AddToFavorites item={item} />
</View>
)}
</View>
<ParallaxScrollView
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeight}
headerImage={
<View style={[{ flex: 1 }]}>
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<ItemHeader item={item} className="mb-4" />
{item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) =>
// setSelectedOptions(
// (prev) => prev && { ...prev, bitrate: val }
// )
console.log("new selected options", val)
}
selected={playbackOptions.bitrate}
/>
<MediaSourceSelector
className="mr-1"
item={item}
onChange={(val) =>
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// mediaSource: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.mediaSource}
/>
<AudioTrackSelector
className="mr-1"
source={playbackOptions.mediaSource}
onChange={(val) => {
// setSelectedOptions((prev) =>
// prev && {
// ...prev,
// audioIndex: val,
// }
// );
console.log("new selected options", val);
}}
selected={playbackOptions.audioIndex}
/>
<SubtitleTrackSelector
source={playbackOptions.mediaSource}
onChange={(val) =>
// setSelectedOptions(
// (prev) =>
// prev && {
// ...prev,
// subtitleIndex: val,
// }
// )
console.log("new selected options", val)
}
selected={playbackOptions.subtitleIndex}
/>
</View>
)}
</View>
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" />
</View>
</ParallaxScrollView>
<View className="pt-2">
{TrickplaySliderMemoized}
<View className="flex flex-row items-center justify-between mt-2">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, "s")}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime, "s")}
</Text>
</View>
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
<Ionicons
name="play-skip-back-outline"
size={30}
color={previousItem ? "white" : "gray"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons name="play-back-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => togglePlay()}
className="flex w-14 h-14 items-center justify-center"
>
{!isBufferingOrLoading ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={50}
color="white"
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="play-forward-outline" size={30} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
<Ionicons
name="play-skip-forward-outline"
size={30}
color={nextItem ? "white" : "gray"}
/>
</TouchableOpacity>
</View>
</View>
{/* TODO find proper placement for these buttons */}
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText="Skip Intro"
/>
<SkipButton
showButton={showSkipCreditButton}
onPress={skipCredit}
buttonText="Skip Credits"
/>
{NextEpisodeButtonMemoized}
</View> */}
</View>
);
}
type TrickplaySliderProps = {
item?: BaseItemDto;
progress: SharedValue<number>;
wasPlayingRef: React.MutableRefObject<boolean>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
range: { min?: SharedValue<number>; max: SharedValue<number> };
play: () => void;
pause: () => void;
seek: (time: number) => void;
};
function TrickplaySlider({
item,
progress,
wasPlayingRef,
isPlaying,
isSeeking,
range,
play,
pause,
seek,
}: TrickplaySliderProps) {
const [isSliding, setIsSliding] = useState(false);
const lastProgressRef = useRef<number>(0);
const min = useSharedValue(range.min?.value || 0);
const {
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
prefetchAllTrickplayImages,
} = useTrickplay(
{
Id: item?.Id,
RunTimeTicks: secondsToTicks(progress.value),
Trickplay: item?.Trickplay,
},
true
);
useEffect(() => {
prefetchAllTrickplayImages();
}, []);
const handleSliderStart = useCallback(() => {
setIsSliding(true);
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [isPlaying]);
const handleSliderComplete = useCallback(async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
seek(Math.max(0, Math.floor(value)));
if (wasPlayingRef.current === true) play();
}, []);
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
const handleSliderChange = useCallback(
debounce((value: number) => {
calculateTrickplayUrl(secondsToTicks(value));
const progressInSeconds = Math.floor(value);
const hours = Math.floor(progressInSeconds / 3600);
const minutes = Math.floor((progressInSeconds % 3600) / 60);
const seconds = progressInSeconds % 60;
setTime({ hours, minutes, seconds });
}, 3),
[]
);
const memoizedRenderBubble = useCallback(() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
return (
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
renderThumb={() => null}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => isSliding && memoizedRenderBubble()}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={range.max}
/>
);
}

View File

@@ -1,20 +1,14 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
MediaStreamType,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -29,16 +23,29 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { useTranslation } from "react-i18next";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -47,56 +54,60 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
colors,
}: Props) => {
const isOffline = useOfflineMode();
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [colorAtom] = useAtom(itemThemeColorAtom);
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
router.push(`/player/direct-player?${q}`);
return;
(q: string) => {
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
router.push(`/player/transcoding-player?${q}`);
router.push(`/player/direct-player?${q}`);
},
[router]
[router, isOffline],
);
const onPress = useCallback(async () => {
const handleNormalPlayFlow = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString, selectedOptions.bitrate?.value);
goToPlayer(queryString);
return;
}
@@ -116,65 +127,155 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS)
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
else {
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
item,
deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
} else {
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast")
t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
chromecastLoadMedia({
client,
item,
contentUrl: data.url,
sessionId: data.sessionId || undefined,
mediaSourceId: data.mediaSource?.Id || undefined,
playbackOptions: selectedOptions,
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}).then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
router.push("/player/google-cast-player");
});
// Calculate start time in seconds from playback position
const startTimeSeconds =
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
// Calculate stream duration in seconds from runtime
const streamDurationSeconds = item.RunTimeTicks
? item.RunTimeTicks / 10000000
: undefined;
client
.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl: data?.url,
contentType: "video/mp4",
streamType: MediaStreamType.BUFFERED,
streamDuration: streamDurationSeconds,
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: startTimeSeconds,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
}
});
break;
case 1:
goToPlayer(queryString, selectedOptions.bitrate?.value);
goToPlayer(queryString);
break;
case cancelButtonIndex:
break;
}
}
},
);
}, [
item,
@@ -186,16 +287,140 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return;
}
// If online but file is downloaded, ask user which version to play
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData && userData.PlaybackPositionTicks) {
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH
MIN_PLAYBACK_WIDTH,
)
: 0;
}
@@ -212,11 +437,11 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item]
[item],
);
useAnimatedReaction(
() => colorAtom,
() => effectiveColors,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -225,19 +450,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom]
[effectiveColors],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startColor.value = effectiveColors;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
}, [effectiveColors, item]);
/**
* ANIMATED STYLES
@@ -246,7 +471,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -254,7 +479,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary]
[startColor.value.primary, endColor.value.primary],
),
}));
@@ -262,7 +487,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value]
[startWidth.value, targetWidth.value],
)}%`,
}));
@@ -270,83 +495,61 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
return (
<View>
<TouchableOpacity
disabled={!item}
accessibilityLabel="Play button"
accessibilityHint="Tap to play the media"
onPress={onPress}
className={`relative`}
{...props}
>
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<TouchableOpacity
disabled={!item}
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative flex-1"}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-full'
/>
<View
style={{
borderWidth: 1,
borderColor: effectiveColors.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
)}
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -107,6 +108,9 @@ export const Home = () => {
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
@@ -196,10 +200,13 @@ export const Home = () => {
const refetch = async () => {
setLoading(true);
setLoadedSections(new Set());
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
try {
setLoadedSections(new Set());
await refreshStreamyfinPluginSettings();
await invalidateCache();
} finally {
setLoading(false);
}
};
const createCollectionConfig = useCallback(

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
TouchableOpacity,
View,
} from "react-native";
@@ -37,6 +38,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefetchHomeOnForeground } from "@/hooks/useRefetchHomeOnForeground";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -67,8 +69,8 @@ export const HomeWithCarousel = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const [loading, setLoading] = useState(false);
const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -91,6 +93,19 @@ export const HomeWithCarousel = () => {
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
// Refresh home data on mount (cold start) and when app returns to foreground
useRefetchHomeOnForeground();
const refetch = async () => {
setLoading(true);
try {
await refreshStreamyfinPluginSettings();
await invalidateCache();
} finally {
setLoading(false);
}
};
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
@@ -178,13 +193,6 @@ export const HomeWithCarousel = () => {
);
}, [userViews]);
const _refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
@@ -540,8 +548,18 @@ export const HomeWithCarousel = () => {
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16}
bounces={false}
bounces={!Platform.isTV}
overScrollMode='never'
refreshControl={
!Platform.isTV ? (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
) : undefined
}
style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => {
@@ -570,7 +588,7 @@ export const HomeWithCarousel = () => {
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<>
<View key='streamystats-sections'>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
@@ -590,7 +608,7 @@ export const HomeWithCarousel = () => {
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists />
)}
</>
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -37,7 +37,18 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
return { ...item, People: people } as BaseItemDto;
}, [item, people]);
const topPeople = useMemo(() => people.slice(0, 3), [people]);
const topPeople = useMemo(() => {
const seen = new Set<string>();
const unique: BaseItemPerson[] = [];
for (const person of people) {
if (person.Id && !seen.has(person.Id)) {
seen.add(person.Id);
unique.push(person);
}
if (unique.length >= 3) break;
}
return unique;
}, [people]);
const renderActorSection = useCallback(
(person: BaseItemPerson, idx: number, total: number) => {

View File

@@ -1,64 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
interface AdjacentEpisodesProps {
item?: BaseItemDto | null;
}
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const api = useAtomValue(apiAtom);
const { data: adjacentItems } = useQuery({
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!api || !item || !item.SeriesId) {
return null;
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
adjacentTo: item.Id,
limit: 3,
fields: ["MediaSources", "MediaStreams", "ParentId"],
});
return res.data.Items || null;
},
enabled:
!!api &&
!!item?.Id &&
!!item?.SeriesId &&
(item?.Type === "Episode" || item?.Type === "Audio"),
staleTime: 0,
});
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
return { previousItem, nextItem };
};

View File

@@ -0,0 +1,32 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState } from "react-native";
/**
* Refetches active home queries on mount (cold start) and whenever
* the app returns to the foreground from background.
*/
export function useRefetchHomeOnForeground() {
const queryClient = useQueryClient();
useEffect(() => {
// On mount (cold start), use cancelRefetch: false so we don't cancel
// an in-flight initial fetch that React Query already started.
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: false },
);
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
// On foreground return, force a fresh refetch
queryClient.refetchQueries(
{ queryKey: ["home"], type: "active" },
{ cancelRefetch: true },
);
}
});
return () => subscription.remove();
}, [queryClient]);
}

Binary file not shown.

View File

@@ -1,10 +1,13 @@
package expo.modules.mpvplayer
import android.content.Context
import android.content.res.AssetManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import java.io.File
import java.io.FileOutputStream
/**
* MPV renderer that wraps libmpv for video playback.
@@ -26,7 +29,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
}
interface Delegate {
fun onPositionChanged(position: Double, duration: Double)
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek()
@@ -46,6 +49,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
// Cached state
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
private var cachedCacheSeconds: Double = 0.0
private var _isPaused: Boolean = true
private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0
@@ -101,6 +105,52 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.create(context)
MPVLib.addObserver(this)
/**
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
*
* Technical Background:
* ====================
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
* even when subtitle tracks are properly detected and loaded.
*
* Why This Is Necessary:
* =====================
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
* mpv cannot access them directly due to sandboxing and library isolation.
*
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
* configured directory, mpv either:
* - Fails silently (subtitles don't appear)
* - Falls back to a default font that may not support the required character set
* - Crashes or produces rendering errors
*
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
*
* Reference:
* =========
* This workaround is documented in the mpv-android project:
* https://github.com/mpv-android/mpv-android/issues/96
*
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
*/
// Create mpv config directory and copy font files
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
//Log.i(TAG, "mpv config dir: $mpvDir")
if (!mpvDir.exists()) mpvDir.mkdirs()
// This needs to be named `subfont.ttf` else it won't work
arrayOf("subfont.ttf").forEach { fileName ->
val file = File(mpvDir, fileName)
if (file.exists()) return@forEach
context.assets
.open(fileName, AssetManager.ACCESS_STREAMING)
.copyTo(FileOutputStream(file))
}
MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", mpvDir.path)
// Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android")
@@ -124,7 +174,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "yes")
MPVLib.setOptionString("sub-scale-with-window", "no")
MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes")
@@ -283,6 +333,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
// Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
@@ -561,7 +612,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
when (property) {
"duration" -> {
cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
}
"time-pos" -> {
cachedPosition = value
@@ -570,9 +621,12 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
if (shouldUpdate) {
lastProgressUpdateTime = now
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
}
}
"demuxer-cache-duration" -> {
cachedCacheSeconds = value
}
}
}

View File

@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double) {
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
@@ -319,7 +319,8 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
onProgress(mapOf(
"position" to position,
"duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0
"progress" to if (duration > 0) position / duration else 0.0,
"cacheSeconds" to cacheSeconds
))
}

View File

@@ -5,7 +5,7 @@ import CoreVideo
import AVFoundation
protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double)
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
@@ -44,6 +44,7 @@ final class MPVLayerRenderer {
// Thread-safe state for playback
private var _cachedDuration: Double = 0
private var _cachedPosition: Double = 0
private var _cachedCacheSeconds: Double = 0
private var _isPaused: Bool = true
private var _playbackSpeed: Double = 1.0
private var _isLoading: Bool = false
@@ -75,6 +76,10 @@ final class MPVLayerRenderer {
get { stateQueue.sync { _cachedPosition } }
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
}
private var cachedCacheSeconds: Double {
get { stateQueue.sync { _cachedCacheSeconds } }
set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } }
}
private var isPaused: Bool {
get { stateQueue.sync { _isPaused } }
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
@@ -164,6 +169,7 @@ final class MPVLayerRenderer {
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
// This is better for PiP as subtitles are baked into the video
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
// Hardware decoding with VideoToolbox
@@ -340,7 +346,8 @@ final class MPVLayerRenderer {
("time-pos", MPV_FORMAT_DOUBLE),
("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG)
("paused-for-cache", MPV_FORMAT_FLAG),
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
]
for (name, format) in properties {
mpv_observe_property(handle, 0, name, format)
@@ -484,7 +491,7 @@ final class MPVLayerRenderer {
cachedDuration = value
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
}
}
case "time-pos":
@@ -499,10 +506,16 @@ final class MPVLayerRenderer {
lastProgressUpdateTime = now
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
}
}
}
case "demuxer-cache-duration":
var value = Double(0)
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
if status >= 0 {
cachedCacheSeconds = value
}
case "pause":
var flag: Int32 = 0
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)

View File

@@ -298,7 +298,7 @@ class MpvPlayerView: ExpoView {
// MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) {
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position
cachedDuration = duration
@@ -313,6 +313,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
"position": position,
"duration": duration,
"progress": duration > 0 ? position / duration : 0,
"cacheSeconds": cacheSeconds,
])
}
}

View File

@@ -15,6 +15,8 @@ export type OnProgressEventPayload = {
position: number;
duration: number;
progress: number;
/** Seconds of video buffered ahead of current position */
cacheSeconds: number;
};
export type OnErrorEventPayload = {

View File

@@ -39,7 +39,7 @@
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "^5.90.18",
"@tanstack/react-pacer": "^0.19.1",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-persist-client": "^5.90.18",
"axios": "^1.7.9",
"expo": "~54.0.31",
@@ -72,12 +72,12 @@
"expo-web-browser": "~15.0.10",
"i18next": "^25.0.0",
"jotai": "2.16.2",
"lodash": "4.17.21",
"lodash": "4.17.23",
"nativewind": "^2.0.11",
"patch-package": "^8.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "16.5.3",
"react-i18next": "16.5.4",
"react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0",
@@ -117,7 +117,7 @@
"devDependencies": {
"@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli": "20.1.1",
"@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.23",

View File

@@ -1,67 +1,156 @@
{
"login": {
"username_required": "Username is required",
"username_required": "Username Is Required",
"error_title": "Error",
"login_title": "Log in",
"login_title": "Log In",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log in",
"login_button": "Log In",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got it",
"connection_failed": "Connection failed",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change server",
"invalid_username_or_password": "Invalid username or password",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "Connect",
"previous_servers": "previous servers",
"clear_button": "Clear",
"search_for_local_servers": "Search for local servers",
"previous_servers": "Previous Servers",
"clear_button": "Clear all",
"swipe_to_remove": "Swipe to remove",
"search_for_local_servers": "Search for Local Servers",
"searching": "Searching...",
"servers": "Servers"
"servers": "Servers",
"saved": "Saved",
"session_expired": "Session Expired",
"please_login_again": "Your saved session has expired. Please log in again.",
"remove_saved_login": "Remove Saved Login",
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
"accounts_count": "{{count}} accounts",
"select_account": "Select Account",
"add_account": "Add Account",
"remove_account_description": "This will remove the saved credentials for {{username}}."
},
"save_account": {
"title": "Save Account",
"save_for_later": "Save this account",
"security_option": "Security Option",
"no_protection": "No protection",
"no_protection_desc": "Quick login without authentication",
"pin_code": "PIN code",
"pin_code_desc": "4-digit PIN required when switching",
"password": "Re-enter password",
"password_desc": "Password required when switching",
"save_button": "Save",
"cancel_button": "Cancel"
},
"pin": {
"enter_pin": "Enter PIN",
"enter_pin_for": "Enter PIN for {{username}}",
"enter_4_digits": "Enter 4 digits",
"invalid_pin": "Invalid PIN",
"setup_pin": "Set Up PIN",
"confirm_pin": "Confirm PIN",
"pins_dont_match": "PINs don't match",
"forgot_pin": "Forgot PIN?",
"forgot_pin_desc": "Your saved credentials will be removed"
},
"password": {
"enter_password": "Enter Password",
"enter_password_for": "Enter password for {{username}}",
"invalid_password": "Invalid password"
},
"home": {
"checking_server_connection": "Checking server connection...",
"no_internet": "No Internet",
"no_items": "No items",
"no_items": "No Items",
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
"go_to_downloads": "Go to downloads",
"checking_server_connection_message": "Checking connection to server",
"go_to_downloads": "Go to Downloads",
"retry": "Retry",
"server_unreachable": "Server Unreachable",
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
"oops": "Oops!",
"error_message": "Something went wrong.\nPlease log out and in again.",
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done",
"go_to_settings_button": "Go to settings",
"read_more": "Read more"
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
},
"settings": {
"settings_title": "Settings",
"log_out_button": "Log out",
"log_out_button": "Log Out",
"categories": {
"title": "Categories"
},
"playback_controls": {
"title": "Playback & Controls"
},
"audio_subtitles": {
"title": "Audio & Subtitles"
},
"appearance": {
"title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
"hide_remote_session_button": "Hide Remote Session Button"
},
"network": {
"title": "Network",
"local_network": "Local Network",
"auto_switch_enabled": "Auto-switch when at home",
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
"local_url": "Local URL",
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
"local_url_placeholder": "http://192.168.1.100:8096",
"home_wifi_networks": "Home WiFi Networks",
"add_current_network": "Add \"{{ssid}}\"",
"not_connected_to_wifi": "Not connected to WiFi",
"no_networks_configured": "No networks configured",
"add_network_hint": "Add your home WiFi network to enable auto-switching",
"current_wifi": "Current WiFi",
"using_url": "Using",
"local": "Local URL",
"remote": "Remote URL",
"not_connected": "Not connected",
"current_server": "Current Server",
"remote_url": "Remote URL",
"active_url": "Active URL",
"not_configured": "Not configured",
"network_added": "Network added",
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to WiFi",
"permission_denied": "Location permission denied",
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
},
"user_info": {
"user_info_title": "User Info",
"user": "User",
@@ -74,32 +163,53 @@
"authorize_button": "Authorize Quick Connect",
"enter_the_quick_connect_code": "Enter the quick connect code...",
"success": "Success",
"quick_connect_autorized": "Quick Connect authorized",
"quick_connect_autorized": "Quick Connect Authorized",
"error": "Error",
"invalid_code": "Invalid code",
"invalid_code": "Invalid Code",
"authorize": "Authorize"
},
"media_controls": {
"media_controls_title": "Media Controls",
"forward_skip_length": "Forward skip length",
"rewind_length": "Rewind length",
"forward_skip_length": "Forward Skip Length",
"rewind_length": "Rewind Length",
"seconds_unit": "s"
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
},
"audio": {
"audio_title": "Audio",
"set_audio_track": "Set Audio Track From Previous Item",
"audio_language": "Audio language",
"audio_language": "Audio Language",
"audio_hint": "Choose a default audio language.",
"none": "None",
"language": "Language"
"language": "Language",
"transcode_mode": {
"title": "Audio Transcoding",
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
"auto": "Auto",
"stereo": "Force Stereo",
"5_1": "Allow 5.1",
"passthrough": "Passthrough"
}
},
"subtitles": {
"subtitle_title": "Subtitles",
"subtitle_hint": "Configure how subtitles look and behave.",
"subtitle_language": "Subtitle language",
"subtitle_mode": "Subtitle Mode",
"set_subtitle_track": "Set Subtitle Track From Previous Item",
"subtitle_size": "Subtitle Size",
"subtitle_hint": "Configure subtitle preference.",
"none": "None",
"language": "Language",
"loading": "Loading",
@@ -109,45 +219,110 @@
"Always": "Always",
"None": "None",
"OnlyForced": "OnlyForced"
}
},
"text_color": "Text Color",
"background_color": "Background Color",
"outline_color": "Outline Color",
"outline_thickness": "Outline Thickness",
"background_opacity": "Background Opacity",
"outline_opacity": "Outline Opacity",
"bold_text": "Bold Text",
"colors": {
"Black": "Black",
"Gray": "Gray",
"Silver": "Silver",
"White": "White",
"Maroon": "Maroon",
"Red": "Red",
"Fuchsia": "Fuchsia",
"Yellow": "Yellow",
"Olive": "Olive",
"Green": "Green",
"Teal": "Teal",
"Lime": "Lime",
"Purple": "Purple",
"Navy": "Navy",
"Blue": "Blue",
"Aqua": "Aqua"
},
"thickness": {
"None": "None",
"Thin": "Thin",
"Normal": "Normal",
"Thick": "Thick"
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
},
"other": {
"other_title": "Other",
"auto_rotate": "Auto rotate",
"video_orientation": "Video orientation",
"video_orientation": "Video Orientation",
"orientation": "Orientation",
"orientations": {
"DEFAULT": "Default",
"DEFAULT": "Follow Device Orientation",
"ALL": "All",
"PORTRAIT": "Portrait",
"PORTRAIT": "Portrait Auto",
"PORTRAIT_UP": "Portrait Up",
"PORTRAIT_DOWN": "Portrait Down",
"LANDSCAPE": "Landscape",
"LANDSCAPE": "Landscape Auto",
"LANDSCAPE_LEFT": "Landscape Left",
"LANDSCAPE_RIGHT": "Landscape Right",
"OTHER": "Other",
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe area in controls",
"safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback"
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"disabled": "Disabled"
},
"downloads": {
"downloads_title": "Downloads",
"download_method": "Download method",
"remux_max_download": "Remux max download",
"auto_download": "Auto download",
"optimized_versions_server": "Optimized versions server",
"save_button": "Save",
"optimized_server": "Optimized Server",
"optimized": "Optimized",
"default": "Default",
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
"read_more_about_optimized_server": "Read more about the optimize server.",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
"downloads_title": "Downloads"
},
"music": {
"title": "Music",
"playback_title": "Playback",
"playback_description": "Configure how music is played.",
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
"lookahead_enabled": "Enable Look-Ahead Caching",
"lookahead_count": "Tracks to Pre-cache",
"max_cache_size": "Max Cache Size"
},
"plugins": {
"plugins_title": "Plugins",
@@ -155,124 +330,201 @@
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Jellyseerr URL...",
"server_url_placeholder": "Seerr URL",
"password": "Password",
"password_placeholder": "Enter password for Jellyfin user {{username}}",
"save_button": "Save",
"clear_button": "Clear",
"login_button": "Login",
"total_media_requests": "Total media requests",
"movie_quota_limit": "Movie quota limit",
"movie_quota_days": "Movie quota days",
"tv_quota_limit": "TV quota limit",
"tv_quota_days": "TV quota days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
"unlimited": "Unlimited"
"total_media_requests": "Total Media Requests",
"movie_quota_limit": "Movie Quota Limit",
"movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
"POPULARITY": "Popularity"
}
},
"marlin_search": {
"enable_marlin_search": "Enable Marlin Search ",
"enable_marlin_search": "Enable Marlin Search",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read more about Marlin.",
"read_more_about_marlin": "Read More About Marlin.",
"save_button": "Save",
"toasts": {
"saved": "Saved"
}
"saved": "Saved",
"refreshed": "Settings refreshed from server"
},
"refresh_from_server": "Refresh Settings from Server"
},
"streamystats": {
"enable_streamystats": "Enable Streamystats",
"disable_streamystats": "Disable Streamystats",
"enable_search": "Use for Search",
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
"home_sections_title": "Home Sections",
"enable_movie_recommendations": "Movie Recommendations",
"enable_series_recommendations": "Series Recommendations",
"enable_promoted_watchlists": "Promoted Watchlists",
"hide_watchlists_tab": "Hide Watchlists Tab",
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
"recommended_movies": "Recommended Movies",
"recommended_series": "Recommended Series",
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "Refresh Settings from Server"
},
"kefinTweaks": {
"watchlist_enabler": "Enable our Watchlist integration",
"watchlist_button": "Toggle Watchlist integration"
}
},
"storage": {
"storage_title": "Storage",
"app_usage": "App {{usedSpace}}%",
"phone_usage": "Phone {{availableSpace}}%",
"size_used": "{{used}} of {{total}} used",
"delete_all_downloaded_files": "Delete All Downloaded Files"
"device_usage": "Device {{availableSpace}}%",
"size_used": "{{used}} of {{total}} Used",
"delete_all_downloaded_files": "Delete All Downloaded Files",
"music_cache_title": "Music Cache",
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
"enable_music_cache": "Enable Music Cache",
"clear_music_cache": "Clear Music Cache",
"music_cache_size": "{{size}} cached",
"music_cache_cleared": "Music cache cleared",
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
"downloaded_songs_size": "{{size}} downloaded",
"downloaded_songs_deleted": "Downloaded songs deleted"
},
"intro": {
"show_intro": "Show intro",
"reset_intro": "Reset intro"
"title": "Intro",
"show_intro": "Show Intro",
"reset_intro": "Reset Intro"
},
"logs": {
"logs_title": "Logs",
"no_logs_available": "No logs available",
"delete_all_logs": "Delete all logs"
"export_logs": "Export Logs",
"click_for_more_info": "Click for More Info",
"level": "Level",
"no_logs_available": "No Logs Available",
"delete_all_logs": "Delete All Logs"
},
"languages": {
"title": "Languages",
"app_language": "App language",
"app_language_description": "Select the language for the app.",
"app_language": "App Language",
"system": "System"
},
"toasts": {
"error_deleting_files": "Error deleting files",
"error_deleting_files": "Error Deleting Files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled",
"connected": "Connected",
"could_not_connect": "Could not connect",
"invalid_url": "Invalid URL"
"background_downloads_disabled": "Background downloads disabled"
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
"queue_hint": "Queue and downloads will be lost on app restart",
"no_items_in_queue": "No items in queue",
"no_downloaded_items": "No downloaded items",
"delete_all_movies_button": "Delete all Movies",
"delete_all_tvseries_button": "Delete all TV-Series",
"delete_all_button": "Delete all",
"active_download": "Active download",
"no_active_downloads": "No active downloads",
"active_downloads": "Active downloads",
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
"no_active_downloads": "No Active Downloads",
"active_downloads": "Active Downloads",
"new_app_version_requires_re_download": "New app version requires re-download",
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
"back": "Back",
"delete": "Delete",
"something_went_wrong": "Something went wrong",
"something_went_wrong": "Something Went Wrong",
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
"eta": "ETA {{eta}}",
"methods": "Methods",
"toasts": {
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted all movies successfully!",
"failed_to_delete_all_movies": "Failed to delete all movies",
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
"download_cancelled": "Download cancelled",
"could_not_cancel_download": "Could not cancel download",
"download_completed": "Download completed",
"download_started_for": "Download started for {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
"download_stated_for_item": "Download started for {{item}}",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",
"download_cancelled": "Download Cancelled",
"could_not_delete_download": "Could Not Delete Download",
"download_paused": "Download Paused",
"could_not_pause_download": "Could Not Pause Download",
"download_resumed": "Download Resumed",
"could_not_resume_download": "Could Not Resume Download",
"download_completed": "Download Completed",
"download_failed": "Download Failed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download completed for {{item}}",
"queued_item_for_optimization": "Queued {{item}} for optimization",
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
"no_response_received_from_server": "No response received from the server",
"error_setting_up_the_request": "Error setting up the request",
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
"download_completed_for_item": "Download Completed for {{item}}",
"download_started_for_item": "Download Started for {{item}}",
"failed_to_start_download": "Failed to start download",
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All Downloads Deleted Successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
"go_to_downloads": "Go to downloads"
"failed_to_clean_cache_directory": "Failed to clean cache directory",
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
"go_to_downloads": "Go to Downloads",
"file_deleted": "{{item}} deleted"
}
}
},
"common": {
"select": "Select",
"no_trailer_available": "No trailer available",
"video": "Video",
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"none": "None",
"track": "Track",
"cancel": "Cancel",
"delete": "Delete",
"ok": "OK",
"remove": "Remove",
"next": "Next",
"back": "Back",
"continue": "Continue",
"verifying": "Verifying..."
},
"search": {
"search_here": "Search here...",
"search": "Search...",
"x_items": "{{count}} items",
"x_items": "{{count}} Items",
"library": "Library",
"discover": "Discover",
"no_results": "No results",
"no_results_found_for": "No results found for",
"no_results": "No Results",
"no_results_found_for": "No Results Found For",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
"collections": "Collections",
"actors": "Actors",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"playlists": "Playlists",
"request_movies": "Request Movies",
"request_series": "Request Series",
"recently_added": "Recently Added",
@@ -298,29 +550,29 @@
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
},
"library": {
"no_items_found": "No items found",
"no_results": "No results",
"no_libraries_found": "No libraries found",
"no_results": "No Results",
"no_libraries_found": "No Libraries Found",
"item_types": {
"movies": "movies",
"series": "series",
"boxsets": "box sets",
"items": "items"
"movies": "Movies",
"series": "Series",
"boxsets": "Box Sets",
"items": "Items"
},
"options": {
"display": "Display",
"row": "Row",
"list": "List",
"image_style": "Image style",
"image_style": "Image Style",
"poster": "Poster",
"cover": "Cover",
"show_titles": "Show titles",
"show_stats": "Show stats"
"show_titles": "Show Titles",
"show_stats": "Show Stats"
},
"filters": {
"genres": "Genres",
"years": "Years",
"sort_by": "Sort By",
"filter_by": "Filter By",
"sort_order": "Sort Order",
"tags": "Tags"
}
@@ -330,32 +582,37 @@
"movies": "Movies",
"episodes": "Episodes",
"videos": "Videos",
"boxsets": "Boxsets",
"playlists": "Playlists"
"boxsets": "Box Sets",
"playlists": "Playlists",
"noDataTitle": "No Favorites Yet",
"noData": "Mark items as favorites to see them appear here for quick access."
},
"custom_links": {
"no_links": "No links"
"no_links": "No Links"
},
"player": {
"error": "Error",
"failed_to_get_stream_url": "Failed to get the stream URL",
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
"client_error": "Client error",
"client_error": "Client Error",
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from server: {{message}}",
"video_has_finished_playing": "Video has finished playing!",
"no_video_source": "No video source...",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"subtitle_tracks": "Subtitle Tracks:",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"no_data_available": "No data available",
"index": "Index:"
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Next up",
"no_items_to_display": "No items to display",
"next_up": "Next Up",
"no_items_to_display": "No Items to Display",
"cast_and_crew": "Cast & Crew",
"series": "Series",
"seasons": "Seasons",
@@ -363,35 +620,34 @@
"no_episodes_for_this_season": "No episodes for this season",
"overview": "Overview",
"more_with": "More with {{name}}",
"similar_items": "Similar items",
"no_similar_items_found": "No similar items found",
"similar_items": "Similar Items",
"no_similar_items_found": "No Similar Items Found",
"video": "Video",
"more_details": "More details",
"more_details": "More Details",
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"show_more": "Show more",
"show_less": "Show less",
"appeared_in": "Appeared in",
"could_not_load_item": "Could not load item",
"show_more": "Show More",
"show_less": "Show Less",
"appeared_in": "Appeared In",
"could_not_load_item": "Could Not Load Item",
"none": "None",
"download": {
"download_season": "Download Season",
"download_series": "Download Series",
"download_episode": "Download Episode",
"download_movie": "Download Movie",
"download_x_item": "Download {{item_count}} items",
"download_button": "Download",
"using_optimized_server": "Using optimized server",
"using_default_method": "Using default method"
"download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only",
"download_button": "Download"
}
},
"live_tv": {
"next": "Next",
"previous": "Previous",
"live_tv": "Live TV",
"coming_soon": "Coming soon",
"on_now": "On now",
"coming_soon": "Coming Soon",
"on_now": "On Now",
"shows": "Shows",
"movies": "Movies",
"sports": "Sports",
@@ -402,16 +658,16 @@
"confirm": "Confirm",
"cancel": "Cancel",
"yes": "Yes",
"whats_wrong": "What's wrong?",
"issue_type": "Issue type",
"select_an_issue": "Select an issue",
"whats_wrong": "What's Wrong?",
"issue_type": "Issue Type",
"select_an_issue": "Select an Issue",
"types": "Types",
"describe_the_issue": "(optional) Describe the issue...",
"describe_the_issue": "(Optional) Describe the Issue...",
"submit_button": "Submit",
"report_issue_button": "Report issue",
"report_issue_button": "Report Issue",
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to login",
"failed_to_login": "Failed to Login",
"cast": "Cast",
"details": "Details",
"status": "Status",
@@ -426,25 +682,33 @@
"production_country": "Production Country",
"studios": "Studios",
"network": "Network",
"currently_streaming_on": "Currently Streaming on",
"currently_streaming_on": "Currently Streaming On",
"advanced": "Advanced",
"request_as": "Request As",
"tags": "Tags",
"quality_profile": "Quality Profile",
"root_folder": "Root Folder",
"season_x": "Season {{seasons}}",
"season_all": "Season (All)",
"season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",
"appearances": "Appearances",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
"issue_submitted": "Issue submitted!",
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
}
},
"tabs": {
@@ -454,14 +718,128 @@
"custom_links": "Custom Links",
"favorites": "Favorites"
},
"chromecast": {
"no_devices_available": "No Google Cast devices available.",
"are_you_on_same_network": "Are you on the same network?",
"no_device_selected": "No device selected",
"click_icon_to_connect": "Click icon to connect.",
"establishing_connection": "Establishing connection...",
"no_media_selected": "No media selected.",
"start_playing": "Start playing any media",
"go_home": "Go Home"
"music": {
"title": "Music",
"tabs": {
"suggestions": "Suggestions",
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
},
"filters": {
"all": "All"
},
"recently_added": "Recently Added",
"recently_played": "Recently Played",
"frequently_played": "Frequently Played",
"explore": "Explore",
"top_tracks": "Top Tracks",
"play": "Play",
"shuffle": "Shuffle",
"play_top_tracks": "Play Top Tracks",
"no_suggestions": "No suggestions available",
"no_albums": "No albums found",
"no_artists": "No artists found",
"no_playlists": "No playlists found",
"album_not_found": "Album not found",
"artist_not_found": "Artist not found",
"playlist_not_found": "Playlist not found",
"track_options": {
"play_next": "Play Next",
"add_to_queue": "Add to Queue",
"add_to_playlist": "Add to Playlist",
"download": "Download",
"downloaded": "Downloaded",
"downloading": "Downloading...",
"cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist",
"go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites",
"remove_from_favorites": "Remove from Favorites",
"remove_from_playlist": "Remove from Playlist"
},
"playlists": {
"create_playlist": "Create Playlist",
"playlist_name": "Playlist Name",
"enter_name": "Enter playlist name",
"create": "Create",
"search_playlists": "Search playlists...",
"added_to": "Added to {{name}}",
"added": "Added to playlist",
"removed_from": "Removed from {{name}}",
"removed": "Removed from playlist",
"created": "Playlist created",
"create_new": "Create New Playlist",
"failed_to_add": "Failed to add to playlist",
"failed_to_remove": "Failed to remove from playlist",
"failed_to_create": "Failed to create playlist",
"delete_playlist": "Delete Playlist",
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleted": "Playlist deleted",
"failed_to_delete": "Failed to delete playlist"
},
"sort": {
"title": "Sort By",
"alphabetical": "Alphabetical",
"date_created": "Date Created"
}
},
"watchlists": {
"title": "Watchlists",
"my_watchlists": "My Watchlists",
"public_watchlists": "Public Watchlists",
"create_title": "Create Watchlist",
"edit_title": "Edit Watchlist",
"create_button": "Create Watchlist",
"save_button": "Save Changes",
"delete_button": "Delete",
"remove_button": "Remove",
"cancel_button": "Cancel",
"name_label": "Name",
"name_placeholder": "Enter watchlist name",
"description_label": "Description",
"description_placeholder": "Enter description (optional)",
"is_public_label": "Public Watchlist",
"is_public_description": "Allow others to view this watchlist",
"allowed_type_label": "Content Type",
"sort_order_label": "Default Sort Order",
"empty_title": "No Watchlists",
"empty_description": "Create your first watchlist to start organizing your media",
"empty_watchlist": "This watchlist is empty",
"empty_watchlist_hint": "Add items from your library to this watchlist",
"not_configured_title": "Streamystats Not Configured",
"not_configured_description": "Configure Streamystats in settings to use watchlists",
"go_to_settings": "Go to Settings",
"add_to_watchlist": "Add to Watchlist",
"remove_from_watchlist": "Remove from Watchlist",
"select_watchlist": "Select Watchlist",
"create_new": "Create New Watchlist",
"item": "item",
"items": "items",
"public": "Public",
"private": "Private",
"you": "You",
"by_owner": "By another user",
"not_found": "Watchlist not found",
"delete_confirm_title": "Delete Watchlist",
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"remove_item_title": "Remove from Watchlist",
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type"
},
"playback_speed": {
"title": "Playback Speed",
"apply_to": "Apply To",
"speed": "Speed",
"scope": {
"media": "This media only",
"show": "This show",
"all": "All media (default)"
}
}
}

View File

@@ -1,60 +0,0 @@
import { SelectedOptions } from "@/components/ItemContent";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { RemoteMediaClient, WebImage } from "react-native-google-cast";
import { ticksToSeconds } from "./time";
export function chromecastLoadMedia({
client,
item,
contentUrl,
sessionId,
mediaSourceId,
images,
playbackOptions,
}: {
client: RemoteMediaClient;
item: BaseItemDto;
contentUrl: string;
sessionId?: string;
mediaSourceId?: string;
images: WebImage[];
playbackOptions: SelectedOptions;
}) {
return client.loadMedia({
mediaInfo: {
contentId: item.Id,
contentUrl,
contentType: "video/mp4",
customData: {
item,
playbackOptions,
sessionId,
mediaSourceId,
},
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images,
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images,
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images,
},
},
startTime: ticksToSeconds(item.UserData?.PlaybackPositionTicks) || 0,
});
}