mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 17:00:23 +01:00
Compare commits
2 Commits
fix/ui-and
...
refactor/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf2bab57bb | ||
|
|
f97852ae98 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
export default {
|
||||
const MediaTypes = {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
@@ -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);
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
@@ -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";
|
||||
@@ -92,12 +91,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 +108,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;
|
||||
@@ -177,46 +165,6 @@ 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 {
|
||||
@@ -359,37 +307,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 +386,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 +509,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 +620,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 +681,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,
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -34,9 +34,20 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG = {
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
const flatten = (
|
||||
obj: LocaleTree,
|
||||
prefix = "",
|
||||
out: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v;
|
||||
else out[key] = v as string;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key, pattern) => {
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
const walk = (dir: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st;
|
||||
let st: ReturnType<typeof statSync>;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src) =>
|
||||
const stripComments = (src: string): string =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key) =>
|
||||
const literalUsed = (key: string): boolean =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key) =>
|
||||
const isUsed = (key: string): boolean =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj, parts) => {
|
||||
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
@@ -259,7 +274,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
@@ -21,8 +21,14 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name, def) => {
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -51,9 +57,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s) =>
|
||||
const tokens = (s: string | null): string[] =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -65,7 +71,7 @@ const tokens = (s) =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a, b) => {
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o) =>
|
||||
const score = (o: Issue): number =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues;
|
||||
let issues: Issue[];
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -123,7 +129,7 @@ if (!matches.length) {
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t) =>
|
||||
const safeTitle = (t: string): string =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const _fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = process.cwd();
|
||||
// const tvosPath = path.join(root, 'iostv');
|
||||
// const iosPath = path.join(root, 'iosmobile');
|
||||
// const androidPath = path.join(root, 'androidmobile');
|
||||
// const androidTVPath = path.join(root, 'androidtv');
|
||||
// const device = process.argv[2];
|
||||
// const platform = process.argv[2];
|
||||
const isTV = process.env.EXPO_TV || false;
|
||||
|
||||
const paths = new Map([
|
||||
["tvos", path.join(root, "iostv")],
|
||||
["ios", path.join(root, "iosmobile")],
|
||||
["android", path.join(root, "androidmobile")],
|
||||
["androidtv", path.join(root, "androidtv")],
|
||||
]);
|
||||
|
||||
// const platformPath = paths.get(platform);
|
||||
|
||||
if (isTV) {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||
"androidtv",
|
||||
)} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
} else {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
|
||||
// target = "";
|
||||
// switch (platform) {
|
||||
// case "tvos":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "ios":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "android":
|
||||
// target = "android";
|
||||
// break;
|
||||
// case "androidtv":
|
||||
// target = "android";
|
||||
// break;
|
||||
// }
|
||||
@@ -1,5 +1,8 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const process = require("node:process");
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message, color = "") {
|
||||
function log(message: string, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -40,7 +43,7 @@ function log(message, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine) {
|
||||
function formatError(errorLine: string): string {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -70,12 +73,15 @@ function formatError(errorLine) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
|
||||
let currentError = [];
|
||||
let currentError: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -136,12 +142,12 @@ function createErrorSummaryTable(errorsByFile) {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck() {
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
||||
let execArgs = null;
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
@@ -174,7 +180,8 @@ function runTypeCheck() {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = execError.stderr || execError.stdout || "";
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
@@ -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);
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*/
|
||||
import type {
|
||||
DeviceProfile,
|
||||
SubtitleProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* Download-specific subtitle profiles.
|
||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||
*/
|
||||
const downloadSubtitleProfiles = [
|
||||
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
|
||||
/**
|
||||
* Generates a device profile optimized for downloads.
|
||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||
*
|
||||
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
|
||||
* @returns {Object} Jellyfin device profile for downloads
|
||||
*/
|
||||
export const generateDownloadProfile = (audioMode = "auto") => {
|
||||
export const generateDownloadProfile = (
|
||||
audioMode: AudioTranscodeModeType = "auto",
|
||||
): DeviceProfile => {
|
||||
// Get the base profile with proper audio codec configuration
|
||||
const baseProfile = generateDeviceProfile({ audioMode });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
};
|
||||
} satisfies DeviceProfile;
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||
// because MPV cannot load them externally over HTTP
|
||||
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
|
||||
"pgssub",
|
||||
"teletext",
|
||||
"vobsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
// Text-based formats - these can be loaded externally by MPV
|
||||
const TEXT_BASED_FORMATS = [
|
||||
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const getSubtitleProfiles = () => {
|
||||
const profiles = [];
|
||||
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
const profiles: SubtitleProfile[] = [];
|
||||
|
||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||
for (const format of IMAGE_BASED_FORMATS) {
|
||||
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
|
||||
};
|
||||
|
||||
// Export for use in player filtering
|
||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||
19
utils/profiles/trackplayer.d.ts
vendored
19
utils/profiles/trackplayer.d.ts
vendored
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
export function generateTrackPlayerProfile(
|
||||
options?: TrackPlayerProfileOptions,
|
||||
): any;
|
||||
|
||||
declare const _default: any;
|
||||
export default _default;
|
||||
@@ -3,23 +3,26 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type {
|
||||
CodecProfile,
|
||||
DeviceProfile,
|
||||
DirectPlayProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
/**
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
*
|
||||
* @typedef {Object} TrackPlayerProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
*/
|
||||
export type PlatformType = "ios" | "android";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for react-native-track-player.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getDirectPlayProfile = (platform) => {
|
||||
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
@@ -39,10 +42,8 @@ const getDirectPlayProfile = (platform) => {
|
||||
|
||||
/**
|
||||
* Audio codec profiles for react-native-track-player.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getCodecProfile = (platform) => {
|
||||
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
@@ -64,12 +65,11 @@ const getCodecProfile = (platform) => {
|
||||
* This profile is specifically for standalone audio playback using:
|
||||
* - AVPlayer on iOS
|
||||
* - ExoPlayer on Android
|
||||
*
|
||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile for track player
|
||||
*/
|
||||
export const generateTrackPlayerProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
export const generateTrackPlayerProfile = (
|
||||
options: TrackPlayerProfileOptions = {},
|
||||
): DeviceProfile => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
|
||||
return {
|
||||
Name: "Track Player",
|
||||
Reference in New Issue
Block a user