mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 17:00:23 +01:00
Compare commits
1 Commits
fix/ui-and
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213aa2d464 |
15
.github/workflows/build-apps.yml
vendored
15
.github/workflows/build-apps.yml
vendored
@@ -11,15 +11,6 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||
# run (artifacts + logs) without needing Expo access.
|
||||
env:
|
||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
||||
|
||||
jobs:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
@@ -240,9 +231,7 @@ jobs:
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
||||
# the production version tier and stops them inflating the store build counter.
|
||||
run: eas build -p ios --local --non-interactive --profile ci
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
@@ -367,7 +356,7 @@ jobs:
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
2
.github/workflows/conflict.yml
vendored
2
.github/workflows/conflict.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 🚩 Apply merge conflict label
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
with:
|
||||
dirtyLabel: '⚔️ merge-conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
|
||||
@@ -1,47 +1,3 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
// Build metadata, injected into `extra.build` and read at runtime via
|
||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||
const git = (args) => {
|
||||
try {
|
||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildMeta = {
|
||||
commit:
|
||||
(
|
||||
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
||||
process.env.GITHUB_SHA ||
|
||||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
||||
git(["rev-parse", "HEAD"]) ||
|
||||
""
|
||||
).slice(0, 7) || null,
|
||||
branch:
|
||||
process.env.EAS_BUILD_GIT_BRANCH ||
|
||||
process.env.GITHUB_HEAD_REF ||
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
||||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
||||
null,
|
||||
profile:
|
||||
process.env.EAS_BUILD_PROFILE ||
|
||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||
null,
|
||||
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
||||
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
||||
runNumber:
|
||||
process.env.GITHUB_RUN_NUMBER ||
|
||||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
||||
null,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
@@ -66,8 +22,6 @@ module.exports = ({ config }) => {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
config.extra = { ...config.extra, build: buildMeta };
|
||||
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
|
||||
@@ -645,7 +645,7 @@ export default function SettingsTV() {
|
||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
label='Vertical Margin'
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
@@ -663,11 +663,11 @@ export default function SettingsTV() {
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
label='Horizontal Alignment'
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
title: "Horizontal Alignment",
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -677,11 +677,11 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
label='Vertical Alignment'
|
||||
value={alignYLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||
title: "Vertical Alignment",
|
||||
options: alignYOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -49,21 +49,7 @@ export default function StreamystatsPage() {
|
||||
);
|
||||
|
||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||
const movieRecsLocked =
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
||||
const seriesRecsLocked =
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
||||
const promotedWatchlistsLocked =
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
||||
const hideWatchlistsTabLocked =
|
||||
pluginSettings?.hideWatchlistsTab?.locked === true;
|
||||
// The input renders the locked admin URL; enablement must follow the same
|
||||
// effective value or every toggle stays disabled until local state syncs.
|
||||
const effectiveUrl = isUrlLocked
|
||||
? (settings?.streamyStatsServerUrl ?? "")
|
||||
: url;
|
||||
const isStreamystatsEnabled = !!effectiveUrl;
|
||||
const isStreamystatsEnabled = !!url;
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
@@ -160,7 +146,7 @@ export default function StreamystatsPage() {
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={effectiveUrl}
|
||||
value={url}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
@@ -185,18 +171,11 @@ export default function StreamystatsPage() {
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||
disabledByAdmin={searchLocked}
|
||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||
>
|
||||
{/* Locked controls show the live admin value and can't be toggled —
|
||||
local form state would let the switch flip while the write guard
|
||||
drops the change. */}
|
||||
<Switch
|
||||
value={
|
||||
searchLocked
|
||||
? settings?.searchEngine === "Streamystats"
|
||||
: useForSearch
|
||||
}
|
||||
disabled={!isStreamystatsEnabled || searchLocked}
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -204,62 +183,52 @@ export default function StreamystatsPage() {
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={movieRecsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
movieRecsLocked
|
||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||
: movieRecs
|
||||
}
|
||||
value={movieRecs}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={seriesRecsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
seriesRecsLocked
|
||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||
: seriesRecs
|
||||
}
|
||||
value={seriesRecs}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={promotedWatchlistsLocked}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
promotedWatchlistsLocked
|
||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||
: promotedWatchlists
|
||||
}
|
||||
value={promotedWatchlists}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={hideWatchlistsTabLocked}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
>
|
||||
<Switch
|
||||
value={
|
||||
hideWatchlistsTabLocked
|
||||
? (settings?.hideWatchlistsTab ?? false)
|
||||
: hideWatchlistsTab
|
||||
}
|
||||
value={hideWatchlistsTab}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
{t("music.missing_library_id")}
|
||||
Missing music library id.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||
}}
|
||||
>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("music.no_track_playing")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500'>No track playing</Text>
|
||||
</View>
|
||||
</BottomSheetModalProvider>
|
||||
);
|
||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
{t("music.now_playing")}
|
||||
Now Playing
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||
{history.length > 0
|
||||
? t("music.playing_from_queue")
|
||||
: t("music.up_next")}
|
||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View className='flex-1 items-center justify-center py-20'>
|
||||
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1267,7 +1267,7 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occurred_while_playing_the_video"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
|
||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||
{t("player.hash_match")}
|
||||
Hash Match
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
@@ -10,7 +10,6 @@ import * as Device from "expo-device";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
@@ -85,8 +84,7 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -335,12 +333,9 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
// Log only the title — serializing the whole notification touches
|
||||
// the deprecated dataString getter (deprecation warning) and dumps
|
||||
// noisy payloads into the console.
|
||||
console.log(
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -535,7 +530,6 @@ function Layout() {
|
||||
closeButton
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -31,7 +31,6 @@
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-clipboard": "~56.0.4",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
@@ -956,8 +955,6 @@
|
||||
|
||||
"expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="],
|
||||
|
||||
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||
|
||||
"expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="],
|
||||
|
||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -62,7 +61,6 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
@@ -94,10 +92,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
@@ -108,7 +103,6 @@ export const BitrateSheet: React.FC<Props> = ({
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -24,7 +23,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
@@ -46,10 +44,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -58,7 +53,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
title={t("item_card.video")}
|
||||
data={item.MediaSources || []}
|
||||
values={selected ? [selected] : []}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import {
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
||||
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
||||
* shows here, AFTER the session is authorized — the login screen itself
|
||||
* unmounts as soon as the user is set, so it can't host the modal.
|
||||
*/
|
||||
export const PendingAccountSaveModal: React.FC = () => {
|
||||
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { saveCurrentAccount } = useJellyfin();
|
||||
|
||||
// A logout before answering drops the intent — it must not resurface on
|
||||
// the next (possibly different) login.
|
||||
useEffect(() => {
|
||||
if (!user && pending) setPending(null);
|
||||
}, [user, pending, setPending]);
|
||||
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<SaveAccountModal
|
||||
visible={!!pending && !!user}
|
||||
username={user?.Name ?? ""}
|
||||
onClose={() => setPending(null)}
|
||||
onSave={(securityType, pinCode) => {
|
||||
const serverName = pending?.serverName;
|
||||
setPending(null);
|
||||
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
||||
(error) => console.warn("Failed to save account:", error),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
const handlePlayInSession = async (sessionId: string) => {
|
||||
if (!api || !item.Id) return;
|
||||
|
||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t("home.sessions.select_session")}
|
||||
</Text>
|
||||
<Text style={styles.modalTitle}>Select Session</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</View>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<Text style={styles.noSessionsText}>
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
No active sessions found
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</Text>
|
||||
{session.NowPlayingItem && (
|
||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||
{t("home.sessions.now_playing")}{" "}
|
||||
Now playing:{" "}
|
||||
{session.NowPlayingItem.SeriesName
|
||||
? `${session.NowPlayingItem.SeriesName} :`
|
||||
: ""}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -50,7 +49,6 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
@@ -60,10 +58,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
}}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
@@ -75,7 +70,6 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
|
||||
{isTranscoding && (
|
||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||
<Text className='text-xs text-purple-400'>
|
||||
{t("home.downloads.transcoding")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||
const base64Image = useMemo(() => {
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
@@ -35,9 +34,8 @@ export const FilterButton = <T,>({
|
||||
...props
|
||||
}: FilterButtonProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
||||
|
||||
const { data: filters, isLoading } = useQuery<T[]>({
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
queryKey: ["filters", title, queryKey, id],
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
@@ -46,15 +44,9 @@ export const FilterButton = <T,>({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* present() must be called here, inside the press handler: calling it
|
||||
from an effect after a state update silently no-ops on the new
|
||||
architecture and the sheet never appears. Opening immediately also
|
||||
replaces the old data-loaded gate that left the button silently
|
||||
dead while options were still loading (the sheet shows a loader). */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setOpen(true);
|
||||
sheetModalRef.current?.present();
|
||||
filters?.length && setOpen(true);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -97,8 +89,6 @@ export const FilterButton = <T,>({
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
modalRef={sheetModalRef}
|
||||
loading={isLoading}
|
||||
data={filters}
|
||||
values={values}
|
||||
set={set}
|
||||
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { isEqual } from "lodash";
|
||||
import type React from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
StyleSheet,
|
||||
@@ -26,21 +19,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props<T> extends ViewProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* Modal ref the opener must use to present() the sheet from inside its
|
||||
* press handler. On the new architecture with Reanimated 4, present()
|
||||
* called from an effect after a state update silently no-ops — the sheet
|
||||
* mounts nothing. Presenting straight from the gesture handler works.
|
||||
*/
|
||||
modalRef: React.RefObject<BottomSheetModal | null>;
|
||||
data?: T[] | null;
|
||||
/** True while the options are loading — shows a loader inside the sheet. */
|
||||
loading?: boolean;
|
||||
values: T[];
|
||||
set: (value: T[]) => void;
|
||||
title: string;
|
||||
@@ -83,18 +66,16 @@ const LIMIT = 100;
|
||||
export const FilterSheet = <T,>({
|
||||
values,
|
||||
data: _data,
|
||||
loading = false,
|
||||
open,
|
||||
set,
|
||||
setOpen,
|
||||
modalRef,
|
||||
title,
|
||||
searchFilter,
|
||||
renderItemLabel,
|
||||
disableSearch = false,
|
||||
multiple = false,
|
||||
}: Props<T>) => {
|
||||
const bottomSheetModalRef = modalRef;
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["85%"], []);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -103,24 +84,19 @@ export const FilterSheet = <T,>({
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
// Filtering and re-rendering the option list on every keystroke blocks the
|
||||
// JS thread on large lists (2000+ tags); the controlled input then snaps the
|
||||
// native text back to a stale value (lost/reappearing letters). Deferring the
|
||||
// value keeps the keystroke render cheap and runs the list update after.
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!deferredSearch) return _data;
|
||||
if (!search) return _data;
|
||||
const results = [];
|
||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
||||
if (_data && searchFilter?.(_data[i], search)) {
|
||||
results.push(_data[i]);
|
||||
}
|
||||
}
|
||||
return results.slice(0, 100);
|
||||
}, [deferredSearch, _data, searchFilter]);
|
||||
}, [search, _data, searchFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0 || disableSearch) return;
|
||||
@@ -151,28 +127,21 @@ export const FilterSheet = <T,>({
|
||||
setData(newData);
|
||||
}, [offset, _data]);
|
||||
|
||||
// Opening is imperative (see the modalRef prop); this effect only closes.
|
||||
// It also never calls dismiss() on a modal that was never presented.
|
||||
const wasPresentedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!open && wasPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index >= 0) {
|
||||
wasPresentedRef.current = true;
|
||||
} else if (index === -1) {
|
||||
wasPresentedRef.current = false;
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderData = useMemo(() => {
|
||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
||||
if (search.length > 0 && showSearch) return filteredData;
|
||||
return data;
|
||||
}, [deferredSearch, showSearch, filteredData, data]);
|
||||
}, [search, filteredData, data]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
@@ -185,54 +154,6 @@ export const FilterSheet = <T,>({
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoized so typing in the search input (urgent render with an unchanged
|
||||
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
|
||||
const renderedRows = useMemo(
|
||||
() =>
|
||||
renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// Match the deep-equality rule used to render the selected
|
||||
// state below — option objects are recreated across renders,
|
||||
// so reference checks would re-add an already selected item.
|
||||
const isSelected = values.some((value) => isEqual(value, item));
|
||||
if (multiple) {
|
||||
if (!isSelected) set(values.concat(item));
|
||||
else set(values.filter((value) => !isEqual(value, item)));
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
} else {
|
||||
if (!isSelected) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||
{values.some((i) => isEqual(i, item)) ? (
|
||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='h-1 divide-neutral-700 '
|
||||
/>
|
||||
</View>
|
||||
)),
|
||||
[renderData, values, multiple, set, setOpen, renderItemLabel],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl'>{title}</Text>
|
||||
{loading ? (
|
||||
<View className='my-8 flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
|
||||
}}
|
||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
||||
>
|
||||
{renderedRows}
|
||||
{renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (multiple) {
|
||||
if (!values.includes(item)) set(values.concat(item));
|
||||
else set(values.filter((v) => v !== item));
|
||||
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
} else {
|
||||
if (!values.includes(item)) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||
>
|
||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||
{values.some((i) => isEqual(i, item)) ? (
|
||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='h-1 divide-neutral-700 '
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length < (_data?.length || 0) && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
disabled = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||
scaleAmount: 1,
|
||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
<Text
|
||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.live")}
|
||||
LIVE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{t("live_tv.title")}
|
||||
Live TV
|
||||
</Text>
|
||||
|
||||
{/* Tab Bar */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -20,16 +20,14 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import type { SavedServer } from "@/utils/secureCredentials";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -39,17 +37,14 @@ export const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const user = useAtomValue(userAtom);
|
||||
const {
|
||||
setServer,
|
||||
login,
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -69,43 +64,13 @@ export const Login: React.FC = () => {
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||
|
||||
// Close the code sheet as soon as the session is authorized — the native
|
||||
// Alert used before had no programmatic dismiss and stayed open after login.
|
||||
// A Quick Connect login with "save account" on flags the post-login save:
|
||||
// the protection picker shows globally once the session exists (this screen
|
||||
// unmounts on login, so it can't host the modal).
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
if (quickConnectCode && saveAccount) {
|
||||
setPendingAccountSave({ serverName });
|
||||
}
|
||||
setQuickConnectCode(null);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopQuickConnectPolling();
|
||||
};
|
||||
}, [stopQuickConnectPolling]);
|
||||
|
||||
// Going back to server selection keeps this component mounted (same screen,
|
||||
// different state), so the unmount cleanup above doesn't run. Without this a
|
||||
// code authorized after leaving would silently log the user in later.
|
||||
useEffect(() => {
|
||||
if (!api?.basePath) {
|
||||
stopQuickConnectPolling();
|
||||
setQuickConnectCode(null);
|
||||
}
|
||||
}, [api?.basePath, stopQuickConnectPolling]);
|
||||
|
||||
// Save account state — only the intent lives here; the protection picker is
|
||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||
// Save account state
|
||||
const [saveAccount, setSaveAccount] = useState(false);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Handle URL params for server connection
|
||||
useEffect(() => {
|
||||
@@ -152,34 +117,55 @@ export const Login: React.FC = () => {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
const ok = await performLogin(credentials.username, credentials.password);
|
||||
// The protection picker shows AFTER a successful login (global modal) —
|
||||
// never for a failed one.
|
||||
if (ok && saveAccount) {
|
||||
setPendingAccountSave({ serverName });
|
||||
if (saveAccount) {
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<boolean> => {
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password, serverName);
|
||||
return true;
|
||||
await login(username, password, serverName, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPendingLogin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAccountConfirm = async (
|
||||
securityType: AccountSecurityType,
|
||||
pinCode?: string,
|
||||
) => {
|
||||
setShowSaveModal(false);
|
||||
if (pendingLogin) {
|
||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||
saveAccount: true,
|
||||
securityType,
|
||||
pinCode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,7 +259,15 @@ export const Login: React.FC = () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
setQuickConnectCode(code);
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
[
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
Alert.alert(
|
||||
@@ -408,7 +402,7 @@ export const Login: React.FC = () => {
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label={t("server.server_url")}
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
@@ -450,11 +444,14 @@ export const Login: React.FC = () => {
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Dismissing only hides the code — polling continues so the login still
|
||||
completes if the code is authorized from another device afterwards. */}
|
||||
<QuickConnectCodeModal
|
||||
code={quickConnectCode}
|
||||
onClose={() => setQuickConnectCode(null)}
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props {
|
||||
/** The Quick Connect code to display, or null when hidden. */
|
||||
code: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the Quick Connect code while the app polls for authorization.
|
||||
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||
* session is authorized — a native alert has no programmatic dismiss and
|
||||
* lingers over the app after login completes.
|
||||
*/
|
||||
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["50%"], []);
|
||||
const isPresentedRef = useRef(false);
|
||||
|
||||
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||
// when the parent clears the code to close the sheet.
|
||||
const lastCodeRef = useRef<string | null>(null);
|
||||
if (code) lastCodeRef.current = code;
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else if (isPresentedRef.current) {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
isPresentedRef.current = false;
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0) {
|
||||
isPresentedRef.current = true;
|
||||
} else if (index === -1 && isPresentedRef.current) {
|
||||
isPresentedRef.current = false;
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const copyCode = useCallback(async () => {
|
||||
const value = code ?? lastCodeRef.current;
|
||||
if (!value) return;
|
||||
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||
// and skip — importing the JS wrapper there would error out.
|
||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||
const Clipboard = await import("expo-clipboard");
|
||||
await Clipboard.setStringAsync(value);
|
||||
toast.success(t("login.code_copied"));
|
||||
}, [code, t]);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("login.quick_connect")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||
onPress={copyCode}
|
||||
>
|
||||
<Text
|
||||
className='text-center font-bold text-5xl text-neutral-100'
|
||||
style={{ letterSpacing: 10 }}
|
||||
>
|
||||
{code ?? lastCodeRef.current}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='copy-outline'
|
||||
size={22}
|
||||
color='white'
|
||||
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||
{t("login.tap_code_to_copy")}
|
||||
</Text>
|
||||
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||
{t("login.quick_connect_instructions")}
|
||||
</Text>
|
||||
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||
{t("login.got_it")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
: t("login.an_unexpected_error_occured");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||
import { ScrollView, 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";
|
||||
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* 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}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* 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. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
showDiscover,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showDiscover) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
}}
|
||||
>
|
||||
<TVSearchTabBadge
|
||||
label={t("search.library")}
|
||||
label='Library'
|
||||
isSelected={searchType === "Library"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TVSearchTabBadge
|
||||
label={t("search.discover")}
|
||||
label='Discover'
|
||||
isSelected={searchType === "Discover"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -18,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||
|
||||
const alignXLabels: Record<AlignX, string> = {
|
||||
left: t("home.settings.subtitles.align.left"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
right: t("home.settings.subtitles.align.right"),
|
||||
left: "Left",
|
||||
center: "Center",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const alignYLabels: Record<AlignY, string> = {
|
||||
top: t("home.settings.subtitles.align.top"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
bottom: t("home.settings.subtitles.align.bottom"),
|
||||
top: "Top",
|
||||
center: "Center",
|
||||
bottom: "Bottom",
|
||||
};
|
||||
|
||||
const alignXOptionGroups = useMemo(() => {
|
||||
@@ -62,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||
title='MPV Subtitle Settings'
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.subtitles.mpv_settings_description")}
|
||||
Advanced subtitle customization for MPV player
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{!isTv && (
|
||||
<>
|
||||
<ListItem
|
||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
>
|
||||
<ListItem title='Vertical Margin'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
step={5}
|
||||
@@ -85,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||
<ListItem title='Horizontal Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignXOptionGroups}
|
||||
trigger={
|
||||
@@ -100,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
title='Horizontal Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||
<ListItem title='Vertical Alignment'>
|
||||
<PlatformDropdown
|
||||
groups={alignYOptionGroups}
|
||||
trigger={
|
||||
@@ -119,13 +115,13 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
title='Vertical Alignment'
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||
<ListItem title='Opaque Background'>
|
||||
<Switch
|
||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||
onValueChange={(value) =>
|
||||
@@ -135,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
</ListItem>
|
||||
|
||||
{settings.mpvSubtitleBackgroundEnabled && (
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<ListItem title='Background Opacity'>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||
step={5}
|
||||
|
||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title='Jellyseerr'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
|
||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Application from "expo-application";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getVersionInfo } from "@/utils/version";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -13,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Graduated build identifier — see utils/version.ts:
|
||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
||||
const { display: version } = getVersionInfo();
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
@@ -107,7 +106,6 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
scaleAmount = 1.05,
|
||||
imageUrlGetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const typography = useScaledTVTypography();
|
||||
@@ -373,7 +371,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
fontWeight: "700",
|
||||
}}
|
||||
>
|
||||
{t("music.now_playing")}
|
||||
Now Playing
|
||||
</Text>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -154,7 +152,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
||||
<Text style={styles.flagText}>Hash Match</Text>
|
||||
</View>
|
||||
)}
|
||||
{result.hearingImpaired && (
|
||||
|
||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText={t("player.skip_intro")}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
@@ -193,7 +193,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText={t("player.skip_credits")}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
|
||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
}
|
||||
>
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
{t("player.still_watching")}
|
||||
Are you still watching ?
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
onPress={toggleOrientation}
|
||||
disabled={isTogglingOrientation}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||
accessibilityLabel='Toggle screen orientation'
|
||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='screen-rotation'
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
@@ -314,13 +312,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||
Video: {formatCodec(info.videoCodec)}
|
||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.audioCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
@@ -328,13 +326,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{streamInfo?.subtitleCodec && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.subtitle")}{" "}
|
||||
{formatCodec(streamInfo.subtitleCodec)}
|
||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.bitrate")}{" "}
|
||||
Bitrate:{" "}
|
||||
{info.videoBitrate
|
||||
? formatBitrate(info.videoBitrate)
|
||||
: info.audioBitrate
|
||||
@@ -344,27 +341,21 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.buffer_seconds", {
|
||||
seconds: info.cacheSeconds.toFixed(1),
|
||||
})}
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
</Text>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
<Text style={textStyle}>
|
||||
{t("player.technical_info.vo")} {info.voDriver}
|
||||
VO: {info.voDriver}
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
{t("player.technical_info.dropped_frames", {
|
||||
count: info.droppedFrames,
|
||||
})}
|
||||
Dropped: {info.droppedFrames} frames
|
||||
</Text>
|
||||
)}
|
||||
{!info && !playMethod && (
|
||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||
)}
|
||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
onRatioChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
{
|
||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label:
|
||||
option.id === "default"
|
||||
? t("player.aspect_ratio_original")
|
||||
: option.label,
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentRatio,
|
||||
onPress: () => handleRatioSelect(option.id),
|
||||
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title={t("player.aspect_ratio")}
|
||||
title='Aspect Ratio'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ const DropdownView = ({
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -103,7 +101,7 @@ const DropdownView = ({
|
||||
// Quality Section
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: t("player.menu.quality"),
|
||||
title: "Quality",
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
@@ -118,7 +116,7 @@ const DropdownView = ({
|
||||
// Subtitle Section
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: t("player.menu.subtitles"),
|
||||
title: "Subtitles",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
@@ -130,7 +128,7 @@ const DropdownView = ({
|
||||
|
||||
// Subtitle Scale Section
|
||||
groups.push({
|
||||
title: t("player.menu.subtitle_scale"),
|
||||
title: "Subtitle Scale",
|
||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
@@ -144,7 +142,7 @@ const DropdownView = ({
|
||||
// Audio Section
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: t("player.menu.audio"),
|
||||
title: "Audio",
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
@@ -158,7 +156,7 @@ const DropdownView = ({
|
||||
// Speed Section
|
||||
if (setPlaybackSpeed) {
|
||||
groups.push({
|
||||
title: t("player.menu.speed"),
|
||||
title: "Speed",
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
@@ -176,8 +174,8 @@ const DropdownView = ({
|
||||
{
|
||||
type: "action" as const,
|
||||
label: showTechnicalInfo
|
||||
? t("player.menu.hide_technical_info")
|
||||
: t("player.menu.show_technical_info"),
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
@@ -187,7 +185,6 @@ const DropdownView = ({
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
t,
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
@@ -220,7 +217,7 @@ const DropdownView = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title={t("player.menu.playback_options")}
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Alert } from "react-native";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
interface UseRemoteControlProps {
|
||||
showControls: boolean;
|
||||
@@ -125,23 +124,17 @@ export function useRemoteControl({
|
||||
|
||||
// Controls are hidden, so confirm before leaving playback.
|
||||
Alert.alert(
|
||||
i18n.t("player.stopPlayback"),
|
||||
"Stop Playback",
|
||||
videoTitleRef.current
|
||||
? i18n.t("player.stopPlayingTitle", {
|
||||
title: videoTitleRef.current,
|
||||
})
|
||||
: i18n.t("player.stopPlayingConfirm"),
|
||||
? `Stop playing "${videoTitleRef.current}"?`
|
||||
: "Are you sure you want to stop playback?",
|
||||
[
|
||||
{
|
||||
text: i18n.t("common.cancel"),
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => onCancelExitRef.current?.(),
|
||||
},
|
||||
{
|
||||
text: i18n.t("common.stop"),
|
||||
style: "destructive",
|
||||
onPress: onBackRef.current,
|
||||
},
|
||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||
],
|
||||
);
|
||||
return true;
|
||||
|
||||
8
eas.json
8
eas.json
@@ -97,14 +97,6 @@
|
||||
"credentialsSource": "local",
|
||||
"config": "ios-production.yml"
|
||||
}
|
||||
},
|
||||
"ci": {
|
||||
"extends": "production",
|
||||
"autoIncrement": false
|
||||
},
|
||||
"ci_tv": {
|
||||
"extends": "production_tv",
|
||||
"autoIncrement": false
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
// Imported from expo-router's bundled copy, NOT "@react-navigation/*": as of
|
||||
// SDK 56 expo-router's Metro check rejects direct @react-navigation imports.
|
||||
import { useRouter } from "expo-router";
|
||||
import { NavigationContext } from "expo-router/react-navigation";
|
||||
import { useCallback, useContext, useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
/**
|
||||
* Drop-in replacement for expo-router's useRouter that automatically
|
||||
* preserves offline state across navigation and guards against duplicate
|
||||
* screens from rapid taps.
|
||||
* preserves offline state across navigation.
|
||||
*
|
||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
||||
* - push() is a no-op while the source screen is not focused, so taps fired
|
||||
* before the pushed screen has rendered (slow devices) can't stack duplicates
|
||||
*
|
||||
* @example
|
||||
* import useRouter from "@/hooks/useAppRouter";
|
||||
@@ -25,18 +19,8 @@ export function useAppRouter() {
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
|
||||
// Optional: undefined when used outside a navigator (root layout, providers).
|
||||
// When present it reflects the focus state of the screen this hook lives in.
|
||||
const navigation = useContext(NavigationContext);
|
||||
|
||||
const push = useCallback(
|
||||
(href: Parameters<typeof router.push>[0]) => {
|
||||
// Rapid-push guard: a push blurs the source screen synchronously in the
|
||||
// navigation state (only the native render is slow). Any further push from
|
||||
// this screen — duplicate or not — is dropped until focus returns, so taps
|
||||
// fired before the pushed screen renders can't stack screens.
|
||||
// No navigation context => nothing to guard (deep-link pushes from root).
|
||||
if (navigation?.isFocused?.() === false) return;
|
||||
if (typeof href === "string") {
|
||||
router.push(href as any);
|
||||
} else {
|
||||
@@ -52,7 +36,7 @@ export function useAppRouter() {
|
||||
} as any);
|
||||
}
|
||||
},
|
||||
[router, isOffline, navigation],
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const replace = useCallback(
|
||||
|
||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
|
||||
@@ -36,7 +36,7 @@ android {
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "androidx.tvprovider:tvprovider:1.1.0"
|
||||
implementation "androidx.core:core-ktx:1.13.1"
|
||||
implementation "androidx.core:core-ktx:1.19.0"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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>
|
||||
|
||||
@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal object TvRecommendationsPublisher {
|
||||
private const val TAG = "TvRecommendations"
|
||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||
private const val KEY_PAYLOAD = "payload"
|
||||
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||
private const val KEY_CHANNEL_ID = "channelId"
|
||||
private const val KEY_PROGRAM_IDS = "programIds"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||
|
||||
@@ -62,61 +61,31 @@ 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
|
||||
|
||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (allProgramIds != null) {
|
||||
if (programIds != null) {
|
||||
var deletedPrograms = 0
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)")
|
||||
}
|
||||
|
||||
// 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()
|
||||
if (channelId > 0L) {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
Log.d(TAG, "clear(): notified channel $channelId")
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
@@ -127,274 +96,128 @@ 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 channelMap = JSONObject(programIdsJson)
|
||||
val channelKeys = channelMap.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelId = channelKeys.next()
|
||||
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||
val providerKeys = inner.keys()
|
||||
while (providerKeys.hasNext()) {
|
||||
val providerId = providerKeys.next()
|
||||
if (inner.optLong(providerId, -1L) == programId) {
|
||||
inner.remove(providerId)
|
||||
if (inner.length() == 0) {
|
||||
channelMap.remove(channelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.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()
|
||||
if (sections.length() == 0) {
|
||||
Log.w(TAG, "synchronize(): no sections in payload")
|
||||
return false
|
||||
}
|
||||
|
||||
val prefs = preferences(context)
|
||||
val allNextProgramIds = JSONObject()
|
||||
var totalActive = 0
|
||||
var totalDeleted = 0
|
||||
|
||||
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()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||
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 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
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Store all channel program IDs for clear() to use
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||
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(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||
"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")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||
|
||||
val previousProgramIds = preferences(context)
|
||||
.getString(KEY_PROGRAM_IDS, 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
|
||||
)
|
||||
|
||||
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) {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
}
|
||||
}
|
||||
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putLong(KEY_CHANNEL_ID, channelId)
|
||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||
.apply()
|
||||
|
||||
logProviderState(context, channelId)
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms 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 channelKey = getChannelKey(displayName)
|
||||
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (existingChannelId > 0L) {
|
||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||
val exists = channelExistsInProvider(context, existingChannelId)
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
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()
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
}
|
||||
|
||||
// Channel truly doesn't exist in provider — recreate
|
||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||
prefs.edit().remove(channelKey).apply()
|
||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, 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 = 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 channelUri = contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
) ?: return -1L
|
||||
|
||||
val channelId = ContentUris.parseId(channelUri)
|
||||
prefs.edit().putLong(channelKey, channelId).apply()
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
storeChannelLogo(context, channelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||
@@ -402,10 +225,6 @@ internal object TvRecommendationsPublisher {
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun getChannelKey(displayName: String): String {
|
||||
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||
}
|
||||
|
||||
private fun upsertPreviewProgram(
|
||||
context: Context,
|
||||
channelId: Long,
|
||||
@@ -430,67 +249,42 @@ 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 uniqueImageUrl = appendCacheBuster(it)
|
||||
val imageUri = Uri.parse(uniqueImageUrl)
|
||||
val imageUri = Uri.parse(it)
|
||||
builder.setPosterArtUri(imageUri)
|
||||
builder.setThumbnailUri(imageUri)
|
||||
}
|
||||
|
||||
|
||||
val contentValues = builder.build().toContentValues()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (previousProgramId > 0L) {
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
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 insertedUri = contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
) ?: return -1L
|
||||
|
||||
val programId = ContentUris.parseId(insertedUri)
|
||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||
return programId
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a stable cache key derived from the image URL.
|
||||
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||
* that changes whenever the image content changes, so a deterministic
|
||||
* hash of the URL is sufficient — the param only changes when the URL
|
||||
* (and therefore the image) actually changes, avoiding unnecessary
|
||||
* re-downloads on every sync.
|
||||
*/
|
||||
private fun appendCacheBuster(imageUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||
return "$imageUrl${separator}_v=$hash"
|
||||
}
|
||||
|
||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(deepLink)
|
||||
@@ -512,17 +306,13 @@ internal object TvRecommendationsPublisher {
|
||||
|
||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||
val bitmap = applicationIconBitmap(context) ?: return
|
||||
try {
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
|
||||
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)
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,14 +341,9 @@ internal object TvRecommendationsPublisher {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||
return preferences(context).getLong(getChannelKey(displayName), -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
|
||||
|
||||
@@ -587,10 +372,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: SecurityException) {
|
||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ContentUris
|
||||
import android.util.Log
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
|
||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||
if (programId > 0L) {
|
||||
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||
}
|
||||
}
|
||||
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||
TvRecommendationsPublisher.refreshFromCache(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
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>
|
||||
> =
|
||||
Platform.OS === "ios"
|
||||
? requireNativeView("TvSearchModule")
|
||||
: ((() => null) as any);
|
||||
> = requireNativeView("TvSearchModule");
|
||||
|
||||
/**
|
||||
* Forwards its ref to the underlying native view so it can be used as a
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-clipboard": "~56.0.4",
|
||||
"expo-constants": "~56.0.16",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
|
||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => {
|
||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||
// as video + trickplay, the total must match.
|
||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||
// Read the live file size on disk so the total reflects actual usage and
|
||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||
// value if the file can't be stat'd.
|
||||
if (item.videoFilePath) {
|
||||
try {
|
||||
const file = new File(filePathToUri(item.videoFilePath));
|
||||
if (file.exists) {
|
||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to stat downloaded file for size:", error);
|
||||
}
|
||||
}
|
||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||
}, 0);
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
}
|
||||
|
||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
let totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
// Also count in-progress downloads (they write straight to their final
|
||||
// path) so the growing file shows up as app usage instead of drifting
|
||||
// into the generic device share until completion.
|
||||
for (const process of processes) {
|
||||
try {
|
||||
const file = new File(
|
||||
Paths.document,
|
||||
`${generateFilename(process.item)}.mp4`,
|
||||
);
|
||||
if (file.exists) {
|
||||
totalSize += file.size ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// File not created yet — ignore.
|
||||
}
|
||||
}
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, [processes]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||
import { APP_VERSION } from "@/utils/version";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -55,7 +53,7 @@ const initialApi = (() => {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
const jellyfinInstance = new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -92,12 +90,6 @@ export const apiAtom = atom<Api | null>(initialApi);
|
||||
export const userAtom = atom<UserDto | null>(initialUser);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
export const cacheVersionAtom = atom<number>(0);
|
||||
// Set by a login flow that wants the account saved: the protection picker
|
||||
// shows AFTER the session is authorized (the login screen unmounts on
|
||||
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
||||
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
interface LoginOptions {
|
||||
saveAccount?: boolean;
|
||||
@@ -115,11 +107,6 @@ interface JellyfinContextValue {
|
||||
serverName?: string,
|
||||
options?: LoginOptions,
|
||||
) => Promise<void>;
|
||||
saveCurrentAccount: (options?: {
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
serverName?: string;
|
||||
}) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
stopQuickConnectPolling: () => void;
|
||||
@@ -148,7 +135,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
return new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -177,52 +164,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// --- Session-expiry handling ----------------------------------------------
|
||||
// When the server revokes the token (e.g. the device/session is deleted), a
|
||||
// 401 can surface from any authenticated request. Without central handling
|
||||
// the dead token stays in storage, so every reload re-fires authed calls →
|
||||
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
||||
// state. A single response interceptor on the authenticated api clears the
|
||||
// session on the first 401 so the app drops cleanly to the login screen.
|
||||
const sessionExpiredRef = useRef(false);
|
||||
|
||||
const handleSessionExpired = useCallback(() => {
|
||||
if (sessionExpiredRef.current) return; // run once per session
|
||||
sessionExpiredRef.current = true;
|
||||
storage.remove("token");
|
||||
storage.remove("user");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
queryClient.clear();
|
||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||
// Saved credentials are kept so the user can quick-login again.
|
||||
}, [setUser, setApi, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
||||
// its own handling — a wrong-password 401 is not a session expiry.
|
||||
if (!api?.accessToken) return;
|
||||
sessionExpiredRef.current = false; // re-arm for this fresh session
|
||||
const interceptorId = api.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
handleSessionExpired();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
api.axiosInstance.interceptors.response.eject(interceptorId);
|
||||
};
|
||||
}, [api, handleSessionExpired]);
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -359,37 +306,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Persist the CURRENT session to secure storage — used by the post-login
|
||||
// save-account modal (the protection picker shows AFTER a successful
|
||||
// login, for both the password and Quick Connect flows).
|
||||
const saveCurrentAccount = useCallback(
|
||||
async (options?: {
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
serverName?: string;
|
||||
}) => {
|
||||
const token = storage.getString("token");
|
||||
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
|
||||
const securityType = options?.securityType || "none";
|
||||
let pinHash: string | undefined;
|
||||
if (securityType === "pin" && options?.pinCode) {
|
||||
pinHash = await hashPIN(options.pinCode);
|
||||
}
|
||||
await saveAccountCredential({
|
||||
serverUrl: api.basePath,
|
||||
serverName: options?.serverName || "",
|
||||
token,
|
||||
userId: user.Id,
|
||||
username: user.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
},
|
||||
[api?.basePath, user],
|
||||
);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
username,
|
||||
@@ -469,7 +385,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
default:
|
||||
throw new Error(
|
||||
t(
|
||||
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -592,9 +508,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||
console.warn("Quick login failed:", error);
|
||||
console.error("Quick login failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -705,62 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
// Validate the token and refresh user data in the background. Do NOT
|
||||
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||
// to render; on success we just refresh it.
|
||||
getUserApi(apiInstance)
|
||||
.getCurrentUser()
|
||||
.then(async (response) => {
|
||||
setUser(response.data);
|
||||
// Dismiss splash screen with cached data immediately,
|
||||
// fetch fresh user data in the background
|
||||
setInitialLoaded(true);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// Expected, handled case (offline, or a token the server rejects —
|
||||
// the UI prompts re-login): warn, don't error. Log only
|
||||
// status/message — never the raw error (axios errors carry the
|
||||
// request config incl. the Authorization header / token).
|
||||
console.warn(
|
||||
"Background user validation failed:",
|
||||
e?.response?.status ?? e?.message ?? "unknown error",
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
});
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Background fetch failed — app already rendered with cached data
|
||||
console.warn("Background user fetch failed, using cached data:", e);
|
||||
}
|
||||
} else {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
@@ -774,7 +680,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
removeServer: () => removeServerMutation.mutateAsync(),
|
||||
login: (username, password, serverName, options) =>
|
||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||
saveCurrentAccount,
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
|
||||
@@ -12,21 +12,18 @@
|
||||
"login_button": "Log in",
|
||||
"quick_connect": "Quick Connect",
|
||||
"enter_code_to_login": "Enter code {{code}} to log in",
|
||||
"quick_connect_instructions": "Enter this code on a signed-in device — you'll be logged in automatically.",
|
||||
"tap_code_to_copy": "Tap the code to copy it",
|
||||
"code_copied": "Code copied",
|
||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||
"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_occurred": "An unexpected error occurred",
|
||||
"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_occurred_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"
|
||||
},
|
||||
@@ -36,7 +33,6 @@
|
||||
"connect_button": "Connect",
|
||||
"previous_servers": "Previous servers",
|
||||
"clear_button": "Clear all",
|
||||
"server_url": "Server URL",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Search for local servers",
|
||||
"searching": "Searching...",
|
||||
@@ -192,7 +188,7 @@
|
||||
"authorize_button": "Authorize Quick Connect",
|
||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||
"success": "Success",
|
||||
"quick_connect_authorized": "Quick Connect authorized",
|
||||
"quick_connect_autorized": "Quick Connect authorized",
|
||||
"error": "Error",
|
||||
"invalid_code": "Invalid code",
|
||||
"authorize": "Authorize"
|
||||
@@ -274,10 +270,6 @@
|
||||
"mpv_subtitle_margin_y": "Vertical margin",
|
||||
"mpv_subtitle_align_x": "Horizontal align",
|
||||
"mpv_subtitle_align_y": "Vertical align",
|
||||
"mpv_settings_title": "MPV Subtitle Settings",
|
||||
"mpv_settings_description": "Advanced subtitle customization for MPV player",
|
||||
"opaque_background": "Opaque Background",
|
||||
"background_opacity": "Background Opacity",
|
||||
"align": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
@@ -306,7 +298,7 @@
|
||||
"show_custom_menu_links": "Show custom menu links",
|
||||
"show_large_home_carousel": "Show large home carousel (beta)",
|
||||
"hide_libraries": "Hide libraries",
|
||||
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"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",
|
||||
"default_quality": "Default quality",
|
||||
"default_playback_speed": "Default playback speed",
|
||||
@@ -393,8 +385,6 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
@@ -450,13 +440,10 @@
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No active sessions",
|
||||
"select_session": "Select Session",
|
||||
"now_playing": "Now playing:"
|
||||
"no_active_sessions": "No active sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"transcoding": "Transcoding",
|
||||
"series": "Series",
|
||||
"movies": "Movies",
|
||||
"other_media": "Other media",
|
||||
@@ -513,8 +500,6 @@
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"stop": "Stop",
|
||||
"open_menu": "Open Menu",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
@@ -616,34 +601,10 @@
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"menu": {
|
||||
"quality": "Quality",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitle_scale": "Subtitle Scale",
|
||||
"audio": "Audio",
|
||||
"speed": "Speed",
|
||||
"playback_options": "Playback Options",
|
||||
"show_technical_info": "Show Technical Info",
|
||||
"hide_technical_info": "Hide Technical Info"
|
||||
},
|
||||
"technical_info": {
|
||||
"video": "Video:",
|
||||
"audio": "Audio:",
|
||||
"subtitle": "Subtitle:",
|
||||
"bitrate": "Bitrate:",
|
||||
"buffer_seconds": "Buffer: {{seconds}}s",
|
||||
"vo": "VO:",
|
||||
"dropped_frames": "Dropped: {{count}} frames",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"mpv_player_title": "MPV player",
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"aspect_ratio_original": "Original",
|
||||
"hash_match": "Hash Match",
|
||||
"still_watching": "Are you still watching?",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from server: {{message}}",
|
||||
@@ -741,7 +702,6 @@
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"title": "Live TV",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"coming_soon": "Coming soon",
|
||||
@@ -813,7 +773,7 @@
|
||||
"request_selected": "Request selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"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!",
|
||||
@@ -826,16 +786,6 @@
|
||||
"failed_to_decline_request": "Failed to decline request"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"play_button": "Play button",
|
||||
"play_hint": "Tap to play the media",
|
||||
"toggle_orientation": "Toggle screen orientation",
|
||||
"toggle_orientation_hint": "Toggles the screen orientation between portrait and landscape"
|
||||
},
|
||||
"not_found": {
|
||||
"title": "This screen doesn't exist.",
|
||||
"go_home": "Go to home screen!"
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
@@ -846,12 +796,6 @@
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"no_track_playing": "No track playing",
|
||||
"queue_empty": "Queue is empty",
|
||||
"playing_from_queue": "Playing from queue",
|
||||
"up_next": "Up next",
|
||||
"now_playing": "Now Playing",
|
||||
"missing_library_id": "Missing music library id.",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useMemo } from "react";
|
||||
import { storage } from "../mmkv";
|
||||
import { useSettings } from "./settings";
|
||||
|
||||
@@ -60,36 +59,32 @@ export const sortOptions: {
|
||||
|
||||
export const useFilterOptions = () => {
|
||||
const { settings } = useSettings();
|
||||
// Memoized so the array identity stays stable across renders. A fresh array
|
||||
// each render cascades into ListHeaderComponent re-creation and, under heavy
|
||||
// re-rendering (active downloads), trips React's max-update-depth guard.
|
||||
// We only show the watchlist option if someone has ticked that setting.
|
||||
return useMemo(
|
||||
() =>
|
||||
settings?.useKefinTweaks
|
||||
? [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
],
|
||||
[settings?.useKefinTweaks],
|
||||
);
|
||||
// We want to only show the watchlist option if someone has ticked that setting.
|
||||
const filterOptions = settings?.useKefinTweaks
|
||||
? [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
{ key: FilterByOption.Likes, value: "Watchlist" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: FilterByOption.IsFavoriteOrLiked,
|
||||
value: "Is Favorite Or Liked",
|
||||
},
|
||||
{ key: FilterByOption.IsUnplayed, value: "Is Unplayed" },
|
||||
{ key: FilterByOption.IsPlayed, value: "Is Played" },
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
export const sortOrderOptions: {
|
||||
|
||||
@@ -504,17 +504,7 @@ export const useSettings = () => {
|
||||
if (!_settings) {
|
||||
return;
|
||||
}
|
||||
// Admin-locked settings are enforced at write time too: a control that
|
||||
// isn't disabled in the UI must not persist a value the admin pinned.
|
||||
// The read memo already overrides locked keys, but without this guard the
|
||||
// write would silently land in user storage and resurface once unlocked.
|
||||
const sanitizedUpdate = Object.fromEntries(
|
||||
Object.entries(update).filter(
|
||||
([key]) => pluginSettings?.[key as keyof Settings]?.locked !== true,
|
||||
),
|
||||
) as Partial<Settings>;
|
||||
|
||||
const hasChanges = Object.entries(sanitizedUpdate).some(
|
||||
const hasChanges = Object.entries(update).some(
|
||||
([key, value]) => _settings[key as keyof Settings] !== value,
|
||||
);
|
||||
|
||||
@@ -523,7 +513,7 @@ export const useSettings = () => {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...sanitizedUpdate,
|
||||
...update,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as Application from "expo-application";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
|
||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||
|
||||
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
|
||||
export interface BuildMeta {
|
||||
commit?: string | null;
|
||||
branch?: string | null;
|
||||
profile?: string | null;
|
||||
runNumber?: string | null;
|
||||
builtAt?: string | null;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
|
||||
version: string | null;
|
||||
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
|
||||
build: string | null;
|
||||
/** Short git commit the build was made from, e.g. "a1b2c3d". */
|
||||
commit: string | null;
|
||||
/** Git branch the build was made from, e.g. "develop". */
|
||||
branch: string | null;
|
||||
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
||||
profile: string | null;
|
||||
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
||||
runNumber: string | null;
|
||||
isDev: boolean;
|
||||
isProduction: boolean;
|
||||
/** Graduated label for the Settings "App version" row (see tiering below). */
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a graduated version string for Settings.
|
||||
*
|
||||
* Tiering (most → least detailed):
|
||||
* - dev / local build → `version · branch · commit` (full context for debugging)
|
||||
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
||||
* Actions run number maps the build to its run — artifacts + logs — without
|
||||
* Expo access)
|
||||
* - production (store / TestFlight) → `version` (build number intentionally
|
||||
* not shown: TestFlight already displays it to testers, and the commit pins the
|
||||
* binary better)
|
||||
*/
|
||||
export function getVersionInfo(): VersionInfo {
|
||||
// Read native/config values defensively — a version string must never crash Settings
|
||||
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
|
||||
const read = <T>(fn: () => T): T | null => {
|
||||
try {
|
||||
return fn() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const version = read(() => Application.nativeApplicationVersion);
|
||||
const build = read(() => Application.nativeBuildVersion);
|
||||
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
|
||||
{}) as BuildMeta;
|
||||
const commit = meta.commit ?? null;
|
||||
const branch = meta.branch ?? null;
|
||||
const profile = meta.profile ?? null;
|
||||
const runNumber = meta.runNumber ?? null;
|
||||
const isDev = __DEV__ === true;
|
||||
const isProduction =
|
||||
typeof profile === "string" && profile.startsWith("production");
|
||||
|
||||
let display: string;
|
||||
if (isDev) {
|
||||
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
||||
} else if (isProduction) {
|
||||
display = version ?? build ?? "N/A";
|
||||
} else {
|
||||
display =
|
||||
[version, commit, runNumber && `#${runNumber}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "N/A";
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
build,
|
||||
commit,
|
||||
branch,
|
||||
profile,
|
||||
runNumber,
|
||||
isDev,
|
||||
isProduction,
|
||||
display,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user