Compare commits

..

15 Commits

Author SHA1 Message Date
Lance Chant
36d18e2bec wip: initial implementation of seerr for TV
Allowed the tv to login manually and discover content via search.

Still work to do, I.E centralize the logic for mobile and tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 13:24:11 +02:00
Lance Chant
326956dfda fix: search page crash on android
Ensured the search module is only apple specific to stop android
crashing

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:24:40 +02:00
Lance Chant
7528274249 fix: the home recommendations
there was an issue where the home recommendations was deleted and then
not shown again because of google TV policies

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-05 08:08:03 +02:00
Alex
0d796d01b8 feat(mpv-ios): Fix controls not pressable after resuming from PIP (#1667)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 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
2026-06-04 21:44:04 +10:00
Fredrik Burmester
46d96d5965 fix(music): use white tab labels instead of default blue (#1657)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 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: Gauvain <contact@uruk.dev>
2026-06-02 23:36:50 +02:00
Kylian Ronfleux--Corail
7d16e7d5c7 fix: Improve perf download database and correct crash when changing episode (#1547)
Co-authored-by: BuildTools <unconfigured@null.spigotmc.org>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-02 22:37:16 +02:00
Alex
ceb9b5a1ae fix(downloads): poster images not showing for downloaded content (#1652)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 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: Gauvain <contact@uruk.dev>
2026-06-02 19:47:14 +02:00
Gauvain
1144ff5049 fix(player): correct prev/next episode buttons at boundaries & for missing episodes (#1649) 2026-06-02 19:12:21 +02:00
Alex
4d508a4315 fix(platform-dropdown-component): Auto-size iOS trigger wrapper to pr… (#1650) 2026-06-02 18:40:47 +02:00
Alex
915a4febbb fix(player): respect safe area in chapter list and refine UX (#1653) 2026-06-02 22:31:49 +10:00
Alex
88163eb531 fix(mpv): release audio session on player exit so other apps' audio resumes (#1651)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🚦 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 (typecheck) (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 / 🔍 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
2026-06-02 19:20:56 +10:00
Fredrik Burmester
46bd2a784e fix(tv): keep focus on search field instead of jumping to results grid (#1637)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (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
🚀 Release (EAS build + submit) / 🔐 Approve release (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 Android TV APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🍎 iOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 tvOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android AAB (push) Has been cancelled
🚀 Release (EAS build + submit) / 📦 Draft GitHub Release (push) Has been cancelled
2026-06-01 21:50:52 +02:00
Fredrik Burmester
0a36fdfbec fix: icon alignment library header
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 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
2026-06-01 19:55:55 +02:00
Fredrik Burmester
45d1f752d6 fix: header left button icon alignment 2026-06-01 19:46:07 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
32 changed files with 876 additions and 476 deletions

29
.gitattributes vendored
View File

@@ -1,28 +1 @@
# Normalise line endings to LF for everyone. Files are stored as LF in git and
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
* text=auto eol=lf
# Windows-only scripts must stay CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary assets — never touched / never normalised
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.icns binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.mp3 binary
*.mp4 binary
*.mov binary
*.pdf binary
*.keystore binary
*.jks binary
*.p12 binary
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,51 +1,51 @@
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

11
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
@@ -20,8 +21,10 @@ web-build/
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Native module build outputs (any module)
modules/*/android/build/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
modules/hls-downloader/android/build
# Generated Applications
Streamyfin.app
@@ -66,6 +69,10 @@ certs/
# Version and Backup Files
/version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts
build/

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -21,6 +21,8 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -59,6 +61,37 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } =
useJellyseerrConnect();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const { pluginSettings } = useSettings();
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const handleJellyseerrConnect = useCallback(async () => {
const url = jellyseerrServerUrl.trim();
if (!url) return;
await jellyseerrConnect(url, jellyseerrPassword);
}, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]);
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -883,6 +916,81 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrConnecting}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrConnecting}
/>
<TVSettingsOptionButton
label={
jellyseerrConnecting
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={handleJellyseerrConnect}
disabled={jellyseerrConnecting}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
<View className='pl-1.5'>
<View>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}

View File

@@ -40,6 +40,8 @@ const Layout = () => {
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarActiveTintColor: "#FFFFFF",
tabBarInactiveTintColor: "#9CA3AF",
tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600",

View File

@@ -274,6 +274,11 @@ export default function DirectPlayerPage() {
};
if (itemId) {
setItem(null);
setDownloadedItem(null);
// Clear the previous episode's stream so the loader gate stays closed
// until the new item's stream resolves (avoids a stale MPV source frame).
setStream(null);
fetchItemData();
}
}, [itemId, offline, api, user?.Id]);
@@ -316,6 +321,12 @@ export default function DirectPlayerPage() {
return null;
}
// Ensure item matches the current itemId to avoid race conditions
if (item.Id !== itemId) {
setStreamStatus({ isLoading: false, isError: false });
return null;
}
let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath;
@@ -388,6 +399,7 @@ export default function DirectPlayerPage() {
item,
user?.Id,
downloadedItem,
offline,
]);
useEffect(() => {

View File

@@ -1,13 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// the trigger sizes the wrapper while the Host overlays the real Menu.
return (
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
<View>
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>

View File

@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import {
type ChapterEntry,
chapterStartsMs,
@@ -38,6 +39,7 @@ function ChapterListComponent({
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -79,7 +81,17 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",

View File

@@ -133,7 +133,6 @@ const HomeMobile = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -22,8 +23,6 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps {
item: MovieResult | TvResult;
isFirstItem?: boolean;
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[
animatedStyle,
{
width: 210,
width: posterWidth,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
<View
style={{
width: 210,
width: posterWidth,
aspectRatio: 10 / 15,
borderRadius: 24,
borderRadius: sizes.gaps.small,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
{title}
</Text>
{year && (
{year != null && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
marginTop: sizes.gaps.small,
}}
>
{year}
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{slideTitle}
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
onEndReached={() => {

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -14,8 +15,6 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps {
item: MovieResult | TvResult;
onPress: () => void;
@@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -43,6 +43,8 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return (
<Pressable
onPress={onPress}
@@ -54,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
style={[
animatedStyle,
{
width: 210,
width: posterWidth,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
>
<View
style={{
width: 210,
width: posterWidth,
aspectRatio: 10 / 15,
borderRadius: 24,
borderRadius: sizes.gaps.small,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -117,13 +119,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
marginTop: sizes.gaps.small,
}}
numberOfLines={2}
>
{title}
</Text>
{year && (
{year != null && (
<Text
style={{
fontSize: typography.callout,
@@ -149,6 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
onPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
@@ -157,13 +160,15 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View
style={[
animatedStyle,
{
width: 160,
width: avatarSize,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
@@ -174,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
>
<View
style={{
width: 140,
height: 140,
borderRadius: 70,
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
@@ -198,7 +203,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center",
}}
>
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
<Ionicons
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
@@ -207,7 +216,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
marginTop: 12,
marginTop: sizes.gaps.small,
textAlign: "center",
}}
numberOfLines={2}
@@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<View style={{ marginBottom: sizes.gaps.section }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
}}
>
{title}
@@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
}}
style={{ overflow: "visible" }}
renderItem={({ item }) => (
@@ -400,10 +412,7 @@ export const TVJellyseerrSearchResults: React.FC<
onPersonPress,
}) => {
const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
const typography = useScaledTVTypography();
if (loading) {
return null;
@@ -414,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: 24,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -422,7 +431,9 @@ export const TVJellyseerrSearchResults: React.FC<
>
{t("search.no_results_found_for")}
</Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
<Text
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}"
</Text>
</View>
@@ -431,22 +442,26 @@ export const TVJellyseerrSearchResults: React.FC<
return (
<View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection
title={t("search.request_movies")}
items={movieResults}
isFirstSection={hasMovies}
isFirstSection={false}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={!hasMovies && hasTv}
isFirstSection={false}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={!hasMovies && !hasTv && hasPersons}
isFirstSection={false}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -1,8 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items
const getImageUrl = useMemo(() => {
@@ -231,23 +232,51 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING,
}}
>
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
TextInput fallback on Android TV (the native module is Apple-only). */}
{Platform.OS === "ios" ? (
<View
style={{
marginBottom: 24,
height: SEARCH_AREA_HEIGHT,
}}
>
{/* No horizontal margin here: the native tvOS search bar centers
itself and renders a trailing "Hold to Dictate" hint. */}
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/>
</View>
)}
</View>
<ScrollView
@@ -265,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
disabled={isSearchFocused}
/>
</View>
)}
@@ -280,13 +310,18 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
{sections.map((section) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
// Never auto-focus a result. The native search field owns focus
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
// contentOffset only applies on initial mount; since this FlatList is
// reused across searches (stable key), a second search left the inset
// without the offset and the grid snapped flush to the left edge.
contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING,
}}
/>

View File

@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter();
const isOffline = useOfflineMode();
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot, so refetches after
// updateDownloadedItem() reflect the latest state instead of a stale
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload();
const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
onPress={() => {
router.setParams({ id: _item.Id });
}}
className={`flex flex-col w-44
className={`flex flex-col w-44
${item?.Id === _item.Id ? "" : "opacity-50"}
`}
>

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,9 +75,6 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Chapter props
chapterPositions?: number[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[
{
position: "absolute",
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
right: insets.right,
left: insets.left,
bottom: Math.max(insets.bottom - 17, 0),
},
]}
className={"flex flex-col px-2"}
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual}
/>
)}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View>
</View>
<View

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: "50%",
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: insets.left,
right: insets.right,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",

View File

@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
{ applyLanguagePreferences: true },
);
const queryParams = new URLSearchParams({
// Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
});
},
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
[
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
);
const goToPreviousItem = useCallback(() => {
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
{t("player.ends_at", { time: getFinishTime() })}
</Text>
</View>
)}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
{t("player.ends_at", { time: getFinishTime() })}
</Text>
</View>
)}

View File

@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,10 +16,10 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
// Set the initial season index
useEffect(() => {
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}
}, []);
// Read the live (cached) downloads DB inside the query rather than the
// provider's downloadedItems snapshot. The snapshot only refreshes on the
// provider refreshKey, so after updateDownloadedItem() invalidates
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
const { getDownloadedItems } = useDownload();
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black",
height: "100%",
width: "100%",
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View

View File

@@ -5,12 +5,11 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { settings } = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const insets = useControlsSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[
{
position: "absolute",
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
top: insets.top,
left: insets.left,
right: insets.right,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
},
]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0);
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20,
}
: {
top:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
const textStyle = Platform.isTV

View File

@@ -0,0 +1,18 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -1,3 +1,4 @@
import { File, Paths } from "expo-file-system";
import { useCallback } from "react";
import { storage } from "@/utils/mmkv";
@@ -12,36 +13,28 @@ const useImageStorage = () => {
}
}, []);
/**
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
* resolves to an empty payload under RN's New Architecture.
*/
const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null;
let blob: Blob;
const tmpFile = new File(
Paths.cache,
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
);
try {
// Fetch the data from the URL
const response = await fetch(url);
blob = await response.blob();
const downloaded = await File.downloadFileAsync(url, tmpFile, {
idempotent: true,
});
return await downloaded.base64();
} catch (error) {
console.warn("Error fetching image:", error);
return null;
} finally {
if (tmpFile.exists) tmpFile.delete();
}
// Create a FileReader instance
const reader = new FileReader();
// Convert blob to base64
return new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Extract the base64 string (remove the data URL prefix)
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to convert image to base64"));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}, []);
const saveImage = useCallback(

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0,
});
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
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]);
if (!adjacentItems || currentIndex <= 0) return null;
const candidate = adjacentItems[currentIndex - 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/** The next item in the series */
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]);
if (!adjacentItems || currentIndex < 0) return null;
const candidate = adjacentItems[currentIndex + 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/**
* Reports playback progress.

View File

@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
}
}
}

View File

@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher()
let onError = EventDispatcher()
let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher()
private var currentURL: URL?
private var cachedPosition: Double = 0
@@ -81,7 +82,6 @@ class MpvPlayerView: ExpoView {
private func setupView() {
clipsToBounds = true
backgroundColor = .black
configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -141,21 +141,26 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func configureAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
let session = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(
.playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
try session.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
// MARK: - Audio Session & Notifications
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -270,6 +275,7 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -440,6 +446,7 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -519,9 +526,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
syncNowPlaying(isPlaying: !isPaused())
}
}
@@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
// is `false` when AVKit reports a failure to start, so reflect that.
onPictureInPictureChange(["isActive": didStartPictureInPicture])
}
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill
}
// Notify JS that PiP has fully stopped so the controls overlay can
// be re-mounted when the user returns to full screen.
onPictureInPictureChange(["isActive": false])
}
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

View File

@@ -1,10 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application>
<receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
<intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter>
</receiver>
</application>

View File

@@ -61,31 +61,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean {
val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver
if (programIds != null) {
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = programIds.optLong(key, -1L)
if (programId > 0L) {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
val channelKeys = allProgramIds.keys()
while (channelKeys.hasNext()) {
val channelIdStr = channelKeys.next()
val programIdsJson = allProgramIds.optString(channelIdStr)
if (programIdsJson.isBlank()) continue
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
}
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
}
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
}
if (channelId > 0L) {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
Log.d(TAG, "clear(): notified channel $channelId")
// Also handle legacy format (flat { providerId: programId }) for migration
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
}
prefs.edit()
@@ -96,126 +126,262 @@ internal object TvRecommendationsPublisher {
return true
}
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
if (programIds.optLong(key, -1L) == programId) {
programIds.remove(key)
break
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
if (sections.length() == 0) {
Log.w(TAG, "synchronize(): no sections in payload")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
Log.d(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
// Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
val prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
)
return true
}
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver
if (existingChannelId > 0L) {
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
// Query provider first to verify channel actually exists (prevents recreate bug)
val exists = channelExistsInProvider(context, existingChannelId)
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (exists) {
// Channel exists — update it in place, never recreate
val updated = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildChannelUri(existingChannelId),
updated.toContentValues(),
null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
}
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
// Channel truly doesn't exist in provider — recreate
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply()
}
// Create a new channel
val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val channelUri = contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
) ?: return -1L
val channelUri = try {
contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri)
TvContractCompat.requestChannelBrowsable(context, channelId)
@@ -249,42 +415,62 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it)
}
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it)
val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri)
}
val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver
if (previousProgramId > 0L) {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
try {
val updatedRows = contentResolver.update(
TvContractCompat.buildPreviewProgramUri(previousProgramId),
contentValues,
null,
null
)
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
}
val insertedUri = contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
) ?: return -1L
val insertedUri = try {
contentResolver.insert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId
}
/**
* Append a cache-busting parameter to ensure unique URIs when images change.
* Per Android docs: "Use unique Uris for all images... the old image will
* continue to appear if you don't change the Uri."
*/
private fun appendCacheBuster(imageUrl: String): String {
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -306,13 +492,17 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
try {
val outputStream = context.contentResolver.openOutputStream(
TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
}
}
@@ -341,9 +531,14 @@ internal object TvRecommendationsPublisher {
return bitmap
}
fun getChannelId(context: Context): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
}
private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver
@@ -372,8 +567,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
}
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
} catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
}
}
}

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo";
import * as React from "react";
import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule");
> =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/**
* Forwards its ref to the underlying native view so it can be used as a

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/**
* Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/
export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) {
return JSON.parse(file) as DownloadsDatabase;
cachedDb = JSON.parse(file) as DownloadsDatabase;
return cachedDb;
}
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
}
/**
* Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
}
/**
* Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/
export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase();
const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
}
}
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items;
}
/**
* Get a downloaded item by its ID
* Build or refresh the item index for O(1) lookups
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
const db = getDownloadsDatabase();
if (db.movies[id]) {
return db.movies[id];
function ensureItemIndex(): void {
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
return episode;
}
}
// Build new index from all items
itemIndex = new Map<string, DownloadedItem>();
const items = getAllDownloadedItems();
for (const item of items) {
if (item.item.Id) {
itemIndex.set(item.item.Id, item);
}
}
if (db.other?.[id]) {
return db.other[id];
}
indexCacheVersion = cacheVersion;
}
return undefined;
/**
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
}
/**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/
export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
}

View File

@@ -592,7 +592,8 @@
"continue": "Continue",
"verifying": "Verifying...",
"login": "Login",
"refresh": "Refresh"
"refresh": "Refresh",
"loading": "Loading..."
},
"search": {
"search": "Search...",