mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-02 10:32:50 +01:00
Compare commits
6 Commits
fix/ui-and
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f31cd2b32 | ||
|
|
faa250bfdd | ||
|
|
28a75a2b8c | ||
|
|
aa0eb0a655 | ||
|
|
286a3cad47 | ||
|
|
97b6a912e0 |
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
|
## 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
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
|
|||||||
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
# run (artifacts + logs) without needing Expo access.
|
# run (artifacts + logs) without needing Expo access.
|
||||||
|
|||||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
|||||||
bun-version: "1.3.14"
|
bun-version: "1.3.14"
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
- name: 🔍 Detect duplicate issues
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
run: bun scripts/detect-duplicate-issue.ts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,10 +12,6 @@ web-build/
|
|||||||
# Platform-specific Build Directories
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
|
||||||
/iosmobile
|
|
||||||
/androidmobile
|
|
||||||
/androidtv
|
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
|
|
||||||
## Coding Standards
|
## 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 functional React components with hooks
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
// Registers the tsx require hook so the TypeScript config plugins referenced
|
||||||
|
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
||||||
|
import "tsx/cjs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
const git = (args) => {
|
const git = (args: string[]): string | null => {
|
||||||
try {
|
try {
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
@@ -42,16 +46,16 @@ const buildMeta = {
|
|||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ({ config }) => {
|
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins?.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins?.push([
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
cameraPermission:
|
cameraPermission:
|
||||||
@@ -61,7 +65,7 @@ module.exports = ({ config }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
const androidConfig = {};
|
const androidConfig: { googleServicesFile?: string } = {};
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
@@ -71,5 +75,5 @@ module.exports = ({ config }) => {
|
|||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
};
|
} as ExpoConfig;
|
||||||
};
|
};
|
||||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.js",
|
"./plugins/withExcludeMedia3Dash.ts",
|
||||||
"./plugins/withTVUserManagement.js",
|
"./plugins/withTVUserManagement.ts",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -134,17 +134,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
["./plugins/with-runtime-framework-headers.ts"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||||
["./plugins/withAndroidAlertColors.js"],
|
["./plugins/withAndroidAlertColors.ts"],
|
||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.ts"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.ts"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.ts"],
|
||||||
["./plugins/withTVOSAppIcon.js"],
|
["./plugins/withTVOSAppIcon.ts"],
|
||||||
["./plugins/withTVOSTopShelf.js"],
|
["./plugins/withTVOSTopShelf.ts"],
|
||||||
["./plugins/withTVXcodeEnv.js"],
|
["./plugins/withTVXcodeEnv.ts"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.ts",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -157,6 +158,7 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
@@ -33,13 +33,16 @@ import {
|
|||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
AudioTranscodeMode,
|
AudioTranscodeMode,
|
||||||
|
getActiveVideoPlayer,
|
||||||
InactivityTimeout,
|
InactivityTimeout,
|
||||||
type MpvCacheMode,
|
type MpvCacheMode,
|
||||||
type MpvVoDriver,
|
type MpvVoDriver,
|
||||||
TVTypographyScale,
|
TVTypographyScale,
|
||||||
useSettings,
|
useSettings,
|
||||||
|
VideoPlayer,
|
||||||
} from "@/utils/atoms/settings";
|
} from "@/utils/atoms/settings";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
import {
|
import {
|
||||||
getPreviousServers,
|
getPreviousServers,
|
||||||
type SavedServer,
|
type SavedServer,
|
||||||
@@ -262,6 +265,25 @@ export default function SettingsTV() {
|
|||||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||||
const currentLanguage = settings.preferedLanguage;
|
const currentLanguage = settings.preferedLanguage;
|
||||||
|
|
||||||
|
// Video player selection. MPV is the default; ExoPlayer is only offered
|
||||||
|
// as an opt-in alternative on Android TV. The selector is hidden on
|
||||||
|
// other platforms.
|
||||||
|
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
|
||||||
|
const currentVideoPlayer = getActiveVideoPlayer(settings);
|
||||||
|
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
|
||||||
|
|
||||||
|
// Shared style for the ExoPlayer / MPV limitation notes shown under the
|
||||||
|
// selector when the respective player is active. All pixel values scaled
|
||||||
|
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
|
||||||
|
const playerNoteStyle = {
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
marginTop: scaleSize(4),
|
||||||
|
marginBottom: scaleSize(12),
|
||||||
|
marginLeft: scaleSize(8),
|
||||||
|
marginRight: scaleSize(8),
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Audio transcoding options
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -391,6 +413,23 @@ export default function SettingsTV() {
|
|||||||
[t, currentVoDriver],
|
[t, currentVoDriver],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Video player backend options (Android TV only)
|
||||||
|
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.video_player.exoplayer"),
|
||||||
|
value: VideoPlayer.ExoPlayer,
|
||||||
|
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.video_player.mpv"),
|
||||||
|
value: VideoPlayer.MPV,
|
||||||
|
selected: currentVideoPlayer === VideoPlayer.MPV,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentVideoPlayer],
|
||||||
|
);
|
||||||
|
|
||||||
// Typography scale options
|
// Typography scale options
|
||||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -522,6 +561,11 @@ export default function SettingsTV() {
|
|||||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||||
}, [voDriverOptions, t]);
|
}, [voDriverOptions, t]);
|
||||||
|
|
||||||
|
const videoPlayerLabel = useMemo(() => {
|
||||||
|
const option = videoPlayerOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "MPV";
|
||||||
|
}, [videoPlayerOptions]);
|
||||||
|
|
||||||
const languageLabel = useMemo(() => {
|
const languageLabel = useMemo(() => {
|
||||||
if (!currentLanguage) return t("home.settings.languages.system");
|
if (!currentLanguage) return t("home.settings.languages.system");
|
||||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||||
@@ -586,6 +630,34 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
{/* Audio Section */}
|
{/* Audio Section */}
|
||||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
|
||||||
|
{/* Video Player selector — Android TV only */}
|
||||||
|
{isAndroidTv && (
|
||||||
|
<>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.video_player.title")}
|
||||||
|
value={videoPlayerLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.video_player.title"),
|
||||||
|
options: videoPlayerOptions,
|
||||||
|
onSelect: (value) => updateSettings({ videoPlayer: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!isMpv && (
|
||||||
|
<Text style={playerNoteStyle}>
|
||||||
|
{t("home.settings.video_player.exoplayer_note")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{isMpv && (
|
||||||
|
<Text style={playerNoteStyle}>
|
||||||
|
{t("home.settings.video_player.mpv_note")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.audio.transcode_mode.title")}
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
value={audioTranscodeLabel}
|
value={audioTranscodeLabel}
|
||||||
@@ -645,7 +717,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
label='Vertical Margin'
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -662,9 +734,11 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{isMpv && (
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
|
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
@@ -676,12 +750,13 @@ export default function SettingsTV() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
label='Vertical Alignment'
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
title: "Vertical Alignment",
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -748,7 +823,9 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Output Section */}
|
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
||||||
|
{isMpv && (
|
||||||
|
<>
|
||||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label={t("home.settings.vo_driver.vo_mode")}
|
label={t("home.settings.vo_driver.vo_mode")}
|
||||||
@@ -761,6 +838,9 @@ export default function SettingsTV() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label={t("home.settings.buffer.buffer_duration")}
|
label={t("home.settings.buffer.buffer_duration")}
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
@@ -854,13 +934,6 @@ export default function SettingsTV() {
|
|||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsToggle
|
|
||||||
label={t("home.settings.appearance.use_episode_images_next_up")}
|
|
||||||
value={settings.useEpisodeImagesForNextUp}
|
|
||||||
onToggle={(value) =>
|
|
||||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TVSettingsToggle
|
<TVSettingsToggle
|
||||||
label={t("home.settings.appearance.show_home_backdrop")}
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, Switch, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -51,12 +50,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4 pt-4'
|
className='px-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -72,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<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>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -47,7 +46,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -61,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<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>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -8,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -75,25 +72,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
const copyLog = useCallback(
|
|
||||||
async (log: NonNullable<typeof logs>[number]) => {
|
|
||||||
// Skip on builds that don't ship the expo-clipboard native module
|
|
||||||
// (probe returns null instead of throwing); same guard as Quick Connect.
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
const text = [
|
|
||||||
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
|
||||||
log.message,
|
|
||||||
log.data ? JSON.stringify(log.data, null, 2) : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
await Clipboard.setStringAsync(text);
|
|
||||||
toast.success(t("home.settings.logs.copied"));
|
|
||||||
},
|
|
||||||
[logs, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -110,15 +88,8 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View className='flex-1'>
|
||||||
// Like the sibling settings pages, let iOS auto-inset the content below the
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
// transparent header (no manual header-height math). The filter bar is a
|
|
||||||
// sticky header so it stays pinned just under the header while logs scroll.
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
stickyHeaderIndices={[0]}
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
|
||||||
>
|
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -141,7 +112,11 @@ export default function Page() {
|
|||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 px-4'>
|
<ScrollView
|
||||||
|
className='pb-4 px-4'
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col space-y-2'>
|
||||||
{filteredLogs?.map((log, index) => (
|
{filteredLogs?.map((log, index) => (
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -168,41 +143,26 @@ export default function Page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='text-xs'>{log.message}</Text>
|
<Text selectable className='text-xs'>
|
||||||
{/* Keep the whole collapsed row tappable: the hint lives inside
|
{log.message}
|
||||||
the toggle so tapping it expands too. */}
|
</Text>
|
||||||
{log.data && !state[log.timestamp] && (
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{log.data && (
|
||||||
|
<>
|
||||||
|
{!state[log.timestamp] && (
|
||||||
<Text className='text-xs mt-0.5'>
|
<Text className='text-xs mt-0.5'>
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
<ScrollView
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
className='rounded-xl'
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
style={codeBlockStyle}
|
|
||||||
nestedScrollEnabled
|
|
||||||
>
|
|
||||||
{/* Only the raw payload is selectable (per request); the
|
|
||||||
header/message stay tap-to-toggle. */}
|
|
||||||
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{!Platform.isTV && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => copyLog(log)}
|
|
||||||
className='flex flex-row items-center self-end px-2 py-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='copy-outline' size={16} color='white' />
|
|
||||||
<Text className='text-xs ml-1'>
|
|
||||||
{t("home.settings.logs.copy")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -213,5 +173,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup className='mt-4'>
|
<ListGroup>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View>
|
<View className='mb-4'>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,9 +16,12 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4 pt-4'>
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
|
||||||
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
|
||||||
const hasStreamystats = !!pluginSettings?.streamyStatsServerUrl?.value;
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -41,8 +41,15 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!marlinUrlLocked) {
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -53,7 +60,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, marlinUrlLocked, t]);
|
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -65,39 +72,52 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4 pt-4'>
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
<DisabledSetting
|
||||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
disabled={
|
||||||
|
pluginSettings?.searchEngine?.locked === true ||
|
||||||
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
|
}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={searchEngineLocked}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
disabled={searchEngineLocked || hasStreamystats}
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
onValueChange={(val) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
updateSettings({
|
||||||
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<ListGroup className='mt-2'>
|
<DisabledSetting
|
||||||
<ListItem
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
title={t("home.settings.plugins.marlin_search.url")}
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
disabledByAdmin={marlinUrlLocked}
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
>
|
>
|
||||||
|
<View
|
||||||
|
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||||
|
>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
editable={settings.searchEngine === "Marlin"}
|
||||||
className='text-white text-right flex-1'
|
className='text-white'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -108,16 +128,15 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</DisabledSetting>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
|
||||||
const { refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -30,17 +18,6 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
|
||||||
so it lives on the plugins index rather than inside Streamystats. */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -22,7 +22,12 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -44,21 +49,7 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
const isStreamystatsEnabled = !!url;
|
||||||
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 onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -122,6 +113,17 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +134,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4 pt-4'>
|
<View className='px-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -144,7 +146,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={effectiveUrl}
|
value={url}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -169,18 +171,11 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
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 —
|
<Switch
|
||||||
local form state would let the switch flip while the write guard
|
value={useForSearch}
|
||||||
drops the change. */}
|
disabled={!isStreamystatsEnabled}
|
||||||
<SettingSwitch
|
|
||||||
value={
|
|
||||||
searchLocked
|
|
||||||
? settings?.searchEngine === "Streamystats"
|
|
||||||
: useForSearch
|
|
||||||
}
|
|
||||||
disabled={!isStreamystatsEnabled || searchLocked}
|
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -188,62 +183,52 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={movieRecsLocked}
|
disabledByAdmin={
|
||||||
>
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
<SettingSwitch
|
|
||||||
value={
|
|
||||||
movieRecsLocked
|
|
||||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
|
||||||
: movieRecs
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={movieRecs}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={seriesRecsLocked}
|
disabledByAdmin={
|
||||||
>
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
<SettingSwitch
|
|
||||||
value={
|
|
||||||
seriesRecsLocked
|
|
||||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
|
||||||
: seriesRecs
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={seriesRecs}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={promotedWatchlistsLocked}
|
disabledByAdmin={
|
||||||
>
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
<SettingSwitch
|
|
||||||
value={
|
|
||||||
promotedWatchlistsLocked
|
|
||||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
|
||||||
: promotedWatchlists
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={promotedWatchlists}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={hideWatchlistsTabLocked}
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={
|
value={hideWatchlistsTab}
|
||||||
hideWatchlistsTabLocked
|
|
||||||
? (settings?.hideWatchlistsTab ?? false)
|
|
||||||
: hideWatchlistsTab
|
|
||||||
}
|
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
disabled={!isStreamystatsEnabled}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -251,6 +236,15 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
TouchableItemRouter,
|
TouchableItemRouter,
|
||||||
} from "@/components/common/TouchableItemRouter";
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -135,12 +134,6 @@ const page: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
// Collections open with a clean filter slate: the genre/year/tag atoms are
|
|
||||||
// global, so without this the previously viewed library's selection bleeds
|
|
||||||
// in (libraries now keep their own per-library memory).
|
|
||||||
setSelectedGenres([]);
|
|
||||||
setSelectedYears([]);
|
|
||||||
setSelectedTags([]);
|
|
||||||
|
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
|
|
||||||
@@ -211,7 +204,8 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
|
useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"collection-items",
|
"collection-items",
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -332,7 +326,7 @@ const page: React.FC = () => {
|
|||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "reset",
|
key: "reset",
|
||||||
component: <ResetFiltersButton libraryId={collectionId} />,
|
component: <ResetFiltersButton />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genre",
|
key: "genre",
|
||||||
@@ -472,6 +466,7 @@ const page: React.FC = () => {
|
|||||||
setSortBy,
|
setSortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
|
isFetching,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -638,7 +633,6 @@ const page: React.FC = () => {
|
|||||||
// Mobile return
|
// Mobile return
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<FilterSheetProvider>
|
|
||||||
<FlashList
|
<FlashList
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
@@ -676,7 +670,6 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FilterSheetProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import {
|
|||||||
getItemsApi,
|
getItemsApi,
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList, type FlashListRef } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
BackHandler,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -30,7 +35,6 @@ import {
|
|||||||
TouchableItemRouter,
|
TouchableItemRouter,
|
||||||
} from "@/components/common/TouchableItemRouter";
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -40,7 +44,6 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
|
|||||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useFilterReset } from "@/hooks/useFilterReset";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
@@ -52,9 +55,7 @@ import {
|
|||||||
FilterByPreferenceAtom,
|
FilterByPreferenceAtom,
|
||||||
filterByAtom,
|
filterByAtom,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
genrePreferenceAtom,
|
|
||||||
getFilterByPreference,
|
getFilterByPreference,
|
||||||
getMultiFilterPreference,
|
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
SortByOption,
|
SortByOption,
|
||||||
@@ -65,12 +66,11 @@ import {
|
|||||||
sortOrderAtom,
|
sortOrderAtom,
|
||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
tagPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
useFilterOptions,
|
useFilterOptions,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
yearPreferenceAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -85,8 +85,9 @@ const Page = () => {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
filterBy?: string;
|
filterBy?: string;
|
||||||
|
fromSeeAll?: string;
|
||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId, fromSeeAll } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -107,9 +108,6 @@ const Page = () => {
|
|||||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
|
|
||||||
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
|
|
||||||
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
|
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
@@ -120,6 +118,22 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// When this library detail was opened from the home "See All" button, its
|
||||||
|
// libraries stack is just [detail], so the default TV Back would exit to home.
|
||||||
|
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
||||||
|
// route to the library list instead, so the user can switch libraries. Normal
|
||||||
|
// entries from the list keep their native pop-to-list behavior.
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!Platform.isTV || fromSeeAll !== "true") return;
|
||||||
|
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
|
router.replace("/(auth)/(tabs)/(libraries)");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [fromSeeAll, router]),
|
||||||
|
);
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
@@ -191,13 +205,6 @@ const Page = () => {
|
|||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
_setFilterBy(fp ? [fp] : []);
|
_setFilterBy(fp ? [fp] : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genres / years / tags: per-library saved preference (no URL params), so
|
|
||||||
// switching libraries restores each library's own selection instead of
|
|
||||||
// bleeding the previous one.
|
|
||||||
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
|
|
||||||
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
|
|
||||||
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
|
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
sortOrderPreference,
|
sortOrderPreference,
|
||||||
@@ -206,12 +213,6 @@ const Page = () => {
|
|||||||
_setSortBy,
|
_setSortBy,
|
||||||
filterByPreference,
|
filterByPreference,
|
||||||
_setFilterBy,
|
_setFilterBy,
|
||||||
genrePreference,
|
|
||||||
yearPreference,
|
|
||||||
tagPreference,
|
|
||||||
setSelectedGenres,
|
|
||||||
setSelectedYears,
|
|
||||||
setSelectedTags,
|
|
||||||
searchParams.sortBy,
|
searchParams.sortBy,
|
||||||
searchParams.sortOrder,
|
searchParams.sortOrder,
|
||||||
searchParams.filterBy,
|
searchParams.filterBy,
|
||||||
@@ -256,32 +257,6 @@ const Page = () => {
|
|||||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Genres / years / tags: save the per-library memory then update the active
|
|
||||||
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
|
|
||||||
const setGenres = useCallback(
|
|
||||||
(genres: string[]) => {
|
|
||||||
setGenrePreference({ ...genrePreference, [libraryId]: genres });
|
|
||||||
setSelectedGenres(genres);
|
|
||||||
},
|
|
||||||
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setYears = useCallback(
|
|
||||||
(years: string[]) => {
|
|
||||||
setYearPreference({ ...yearPreference, [libraryId]: years });
|
|
||||||
setSelectedYears(years);
|
|
||||||
},
|
|
||||||
[libraryId, yearPreference, setYearPreference, setSelectedYears],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setTags = useCallback(
|
|
||||||
(tags: string[]) => {
|
|
||||||
setTagPreference({ ...tagPreference, [libraryId]: tags });
|
|
||||||
setSelectedTags(tags);
|
|
||||||
},
|
|
||||||
[libraryId, tagPreference, setTagPreference, setSelectedTags],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
@@ -316,6 +291,23 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
|
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
||||||
|
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
||||||
|
// which the native bottom tab reliably auto-pops back to the index (the detail
|
||||||
|
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
||||||
|
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
||||||
|
const didCollapseRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
||||||
|
return;
|
||||||
|
const state = navigation.getState();
|
||||||
|
if (state?.routes && state.routes.length > 1) {
|
||||||
|
didCollapseRef.current = true;
|
||||||
|
const top = state.routes[state.routes.length - 1];
|
||||||
|
navigation.reset({ index: 0, routes: [top] } as any);
|
||||||
|
}
|
||||||
|
}, [navigation, fromSeeAll]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -423,29 +415,6 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
|
|
||||||
|
|
||||||
// Jump the grid to the top when the filters/sort change (incl. reset).
|
|
||||||
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
|
|
||||||
const pendingScrollTopRef = useRef(false);
|
|
||||||
|
|
||||||
// Instant feedback: pin to the top the moment the filters change, without
|
|
||||||
// waiting for the new fetch — and flag a re-pin for once it settles.
|
|
||||||
useEffect(() => {
|
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
|
||||||
pendingScrollTopRef.current = true;
|
|
||||||
}, [filterSignature]);
|
|
||||||
|
|
||||||
// Safety net: FlashList can restore the previous offset as the filtered list
|
|
||||||
// grows, so re-pin once the fetch settles. Pagination keeps the same
|
|
||||||
// signature, so it never re-pins.
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingScrollTopRef.current && !isFetching) {
|
|
||||||
pendingScrollTopRef.current = false;
|
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
|
||||||
}
|
|
||||||
}, [isFetching, flatData]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -561,6 +530,7 @@ const Page = () => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
const generalFilters = useFilterOptions();
|
const generalFilters = useFilterOptions();
|
||||||
|
const settings = useSettings();
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -575,7 +545,7 @@ const Page = () => {
|
|||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
key: "reset",
|
key: "reset",
|
||||||
component: <ResetFiltersButton libraryId={libraryId} />,
|
component: <ResetFiltersButton />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "genre",
|
key: "genre",
|
||||||
@@ -594,7 +564,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Genres || [];
|
return response.data.Genres || [];
|
||||||
}}
|
}}
|
||||||
set={setGenres}
|
set={setSelectedGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -621,7 +591,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Years || [];
|
return response.data.Years || [];
|
||||||
}}
|
}}
|
||||||
set={setYears}
|
set={setSelectedYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -646,7 +616,7 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
return response.data.Tags || [];
|
return response.data.Tags || [];
|
||||||
}}
|
}}
|
||||||
set={setTags}
|
set={setSelectedTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
@@ -726,23 +696,35 @@ const Page = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
setGenres,
|
setSelectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
setYears,
|
setSelectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
setTags,
|
setSelectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
|
isFetching,
|
||||||
filterBy,
|
filterBy,
|
||||||
setFilter,
|
setFilter,
|
||||||
|
settings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
|
// TV Filter bar header
|
||||||
// sort/order can't be forgotten on one path (it used to be reset on neither).
|
const hasActiveFilters =
|
||||||
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
_setFilterBy([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||||
|
|
||||||
// TV Filter options - with "All" option for clearable filters
|
// TV Filter options - with "All" option for clearable filters
|
||||||
const tvGenreFilterOptions = useMemo(
|
const tvGenreFilterOptions = useMemo(
|
||||||
@@ -836,15 +818,15 @@ const Page = () => {
|
|||||||
options: tvGenreFilterOptions,
|
options: tvGenreFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setGenres([]);
|
setSelectedGenres([]);
|
||||||
} else if (selectedGenres.includes(value)) {
|
} else if (selectedGenres.includes(value)) {
|
||||||
setGenres(selectedGenres.filter((g) => g !== value));
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
} else {
|
} else {
|
||||||
setGenres([...selectedGenres, value]);
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
const handleShowYearFilter = useCallback(() => {
|
const handleShowYearFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -852,15 +834,15 @@ const Page = () => {
|
|||||||
options: tvYearFilterOptions,
|
options: tvYearFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setYears([]);
|
setSelectedYears([]);
|
||||||
} else if (selectedYears.includes(value)) {
|
} else if (selectedYears.includes(value)) {
|
||||||
setYears(selectedYears.filter((y) => y !== value));
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
} else {
|
} else {
|
||||||
setYears([...selectedYears, value]);
|
setSelectedYears([...selectedYears, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
const handleShowTagFilter = useCallback(() => {
|
const handleShowTagFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -868,15 +850,15 @@ const Page = () => {
|
|||||||
options: tvTagFilterOptions,
|
options: tvTagFilterOptions,
|
||||||
onSelect: (value: string) => {
|
onSelect: (value: string) => {
|
||||||
if (value === "__all__") {
|
if (value === "__all__") {
|
||||||
setTags([]);
|
setSelectedTags([]);
|
||||||
} else if (selectedTags.includes(value)) {
|
} else if (selectedTags.includes(value)) {
|
||||||
setTags(selectedTags.filter((tag) => tag !== value));
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
} else {
|
} else {
|
||||||
setTags([...selectedTags, value]);
|
setSelectedTags([...selectedTags, value]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
const handleShowSortByFilter = useCallback(() => {
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
showOptions({
|
showOptions({
|
||||||
@@ -924,9 +906,7 @@ const Page = () => {
|
|||||||
// Mobile return
|
// Mobile return
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<FilterSheetProvider>
|
|
||||||
<FlashList
|
<FlashList
|
||||||
ref={flashListRef}
|
|
||||||
key={orientation}
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
@@ -962,7 +942,6 @@ const Page = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FilterSheetProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
{t("music.missing_library_id")}
|
Missing music library id.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ function TVTabLayout() {
|
|||||||
|
|
||||||
const currentTab = segments.find(isTabRoute);
|
const currentTab = segments.find(isTabRoute);
|
||||||
const lastSegment = segments[segments.length - 1] ?? "";
|
const lastSegment = segments[segments.length - 1] ?? "";
|
||||||
const atTabRoot =
|
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||||
isTabRoute(lastSegment) || (lastSegment as string) === "index";
|
|
||||||
|
|
||||||
const tabs: TVNavBarTab[] = useMemo(
|
const tabs: TVNavBarTab[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -73,7 +72,6 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -232,9 +230,7 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
{t("music.no_track_playing")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -271,7 +267,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -722,7 +718,6 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -836,15 +831,13 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
? t("music.playing_from_queue")
|
|
||||||
: t("music.up_next")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<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>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
|
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
@@ -40,7 +41,6 @@ import {
|
|||||||
type MpvOnErrorEventPayload,
|
type MpvOnErrorEventPayload,
|
||||||
type MpvOnPlaybackStateChangePayload,
|
type MpvOnPlaybackStateChangePayload,
|
||||||
type MpvOnProgressEventPayload,
|
type MpvOnProgressEventPayload,
|
||||||
MpvPlayerView,
|
|
||||||
type MpvPlayerViewRef,
|
type MpvPlayerViewRef,
|
||||||
type MpvVideoSource,
|
type MpvVideoSource,
|
||||||
} from "@/modules";
|
} from "@/modules";
|
||||||
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
@@ -364,7 +364,13 @@ export default function DirectPlayerPage() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: generateDeviceProfile(),
|
// Match the device profile to the player that will render the
|
||||||
|
// stream so the server picks a codec/container the player can
|
||||||
|
// actually decode.
|
||||||
|
deviceProfile: generateDeviceProfile({
|
||||||
|
player: getActivePlayerType(settings),
|
||||||
|
audioMode: settings.audioTranscodeMode,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||||
@@ -1277,7 +1283,7 @@ export default function DirectPlayerPage() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MpvPlayerView
|
<VideoPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={videoSource}
|
source={videoSource}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
@@ -1290,7 +1296,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
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);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -329,7 +328,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
{t("player.hash_match")}
|
Hash Match
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<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}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Image } from "expo-image";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -86,8 +85,7 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowAlert: true,
|
||||||
shouldShowList: true,
|
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -352,12 +350,9 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(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(
|
console.log(
|
||||||
"Notification received while app running:",
|
"Notification received while app running",
|
||||||
notification.request.content.title,
|
notification,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -552,7 +547,6 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
60
bun.lock
60
bun.lock
@@ -31,7 +31,6 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-clipboard": "~56.0.4",
|
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -114,6 +113,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.8",
|
"lint-staged": "17.0.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -295,6 +295,58 @@
|
|||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||||
|
|
||||||
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
||||||
@@ -909,6 +961,8 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
@@ -947,8 +1001,6 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -1811,6 +1863,8 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { useMemo, useState } from "react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -62,7 +61,6 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -94,10 +92,7 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
onPress={() => {
|
onPress={() => setOpen(true)}
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
@@ -108,7 +103,6 @@ export const BitrateSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
modalRef={sheetModalRef}
|
|
||||||
title={t("item_card.quality")}
|
title={t("item_card.quality")}
|
||||||
data={sorted}
|
data={sorted}
|
||||||
values={selected ? [selected] : []}
|
values={selected ? [selected] : []}
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
// backdrop owner — otherwise the Thumb tag is requested on the wrong item → black.
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
@@ -63,9 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
// useEpisodePoster in deps so flipping the setting re-computes the URL live
|
}, [item]);
|
||||||
// (no app restart needed).
|
|
||||||
}, [item, useEpisodePoster]);
|
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -24,7 +23,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
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>
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
onPress={() => {
|
onPress={() => setOpen(true)}
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -58,7 +53,6 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
modalRef={sheetModalRef}
|
|
||||||
title={t("item_card.video")}
|
title={t("item_card.video")}
|
||||||
data={item.MediaSources || []}
|
data={item.MediaSources || []}
|
||||||
values={selected ? [selected] : []}
|
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 { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -210,7 +209,6 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -382,7 +380,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<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>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -37,7 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -170,8 +168,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel={t("accessibility.play_button")}
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint={t("accessibility.play_hint")}
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -32,7 +31,6 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -67,9 +65,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>Select Session</Text>
|
||||||
{t("home.sessions.select_session")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -82,7 +78,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
{t("home.sessions.no_active_sessions")}
|
No active sessions found
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -102,7 +98,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
{t("home.sessions.now_playing")}{" "}
|
Now playing:{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${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 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 { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -50,7 +49,6 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
return streams;
|
return streams;
|
||||||
}, [streams, streamType, noneOption]);
|
}, [streams, streamType, noneOption]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
|
|
||||||
if (isTv || (streams && streams.length === 0)) return 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>
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
onPress={() => {
|
onPress={() => setOpen(true)}
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selected === -1 && streamType === "Subtitle"
|
{selected === -1 && streamType === "Subtitle"
|
||||||
@@ -75,7 +70,6 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
<FilterSheet
|
<FilterSheet
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
modalRef={sheetModalRef}
|
|
||||||
title={title}
|
title={title}
|
||||||
data={addNoneToSubtitles || []}
|
data={addNoneToSubtitles || []}
|
||||||
values={
|
values={
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Keyboard, Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
@@ -16,37 +16,30 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Dismiss the keyboard before navigating — otherwise it lingers over the
|
|
||||||
// previous screen (e.g. leaving the Jellyseerr login while typing).
|
|
||||||
const handleBack = () => {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleBack}
|
onPress={() => router.back()}
|
||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Ionicons name='arrow-back' size={24} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handleBack} {...pressableProps}>
|
<Pressable onPress={() => router.back()} {...pressableProps}>
|
||||||
<BlurView
|
<BlurView
|
||||||
{...props}
|
{...props}
|
||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -55,17 +48,14 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleBack}
|
onPress={() => router.back()}
|
||||||
// Match the Settings page back button: chevron flush to the edge with a
|
className=' rounded-full p-2'
|
||||||
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
|
||||||
// too far right). drop-shadow keeps it readable over images.
|
|
||||||
style={{ marginRight: 16 }}
|
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Feather
|
<Ionicons
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='chevron-left'
|
name='arrow-back'
|
||||||
size={28}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
|
||||||
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
|
||||||
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
|
||||||
* FIXED-SIZE box (overflow hidden) and center it:
|
|
||||||
* - the fixed height caps the row height (compact, uniform rows),
|
|
||||||
* - the fixed width + centering keep the switch in the exact same spot in the
|
|
||||||
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
|
||||||
* which shifted the switch sideways on toggle).
|
|
||||||
* iOS renders the switch untouched.
|
|
||||||
*
|
|
||||||
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
|
||||||
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
|
||||||
*/
|
|
||||||
const BOX_W = 40;
|
|
||||||
const BOX_H = 30;
|
|
||||||
const SCALE = 0.9;
|
|
||||||
|
|
||||||
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
|
||||||
if (Platform.OS !== "android") return <Switch {...props} />;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: BOX_W,
|
|
||||||
height: BOX_H,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
{...props}
|
|
||||||
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,7 +35,6 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -174,9 +173,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
{t("home.downloads.transcoding")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
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 base64Image = useMemo(() => {
|
||||||
const seriesId = items[0]?.SeriesId;
|
return storage.getString(items[0].SeriesId!);
|
||||||
return seriesId ? storage.getString(seriesId) : undefined;
|
}, []);
|
||||||
}, [items[0]?.SeriesId]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
import { useFilterSheet } from "./FilterSheetProvider";
|
|
||||||
|
|
||||||
interface FilterButtonProps<T> extends ViewProps {
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,63 +33,22 @@ export const FilterButton = <T,>({
|
|||||||
icon = "filter",
|
icon = "filter",
|
||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
// When a FilterSheetProvider is present (library / collections), all buttons
|
const [open, setOpen] = useState(false);
|
||||||
// share one sheet so two can never stack. Outside a provider (e.g. logs,
|
|
||||||
// discover), fall back to this button's own standalone sheet.
|
|
||||||
const shared = useFilterSheet();
|
|
||||||
|
|
||||||
const { data: filters, isLoading } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
enabled: !!id && !!queryFn && !!queryKey,
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Standalone-mode state (unused in shared mode).
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const sheetModalRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
|
|
||||||
const onButtonPress = useCallback(() => {
|
|
||||||
if (shared) {
|
|
||||||
shared.openFilter({
|
|
||||||
key: `${id}:${queryKey}`,
|
|
||||||
id,
|
|
||||||
queryKey,
|
|
||||||
queryFn,
|
|
||||||
title,
|
|
||||||
values: values as unknown[],
|
|
||||||
set: set as (value: unknown[]) => void,
|
|
||||||
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
|
|
||||||
searchFilter: searchFilter as
|
|
||||||
| ((item: unknown, query: string) => boolean)
|
|
||||||
| undefined,
|
|
||||||
disableSearch,
|
|
||||||
multiple,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// present() must run from the press handler: from an effect after a state
|
|
||||||
// update it silently no-ops on the new architecture and the sheet never
|
|
||||||
// appears.
|
|
||||||
setOpen(true);
|
|
||||||
sheetModalRef.current?.present();
|
|
||||||
}, [
|
|
||||||
shared,
|
|
||||||
id,
|
|
||||||
queryKey,
|
|
||||||
queryFn,
|
|
||||||
title,
|
|
||||||
values,
|
|
||||||
set,
|
|
||||||
renderItemLabel,
|
|
||||||
searchFilter,
|
|
||||||
disableSearch,
|
|
||||||
multiple,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TouchableOpacity onPress={onButtonPress}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
filters?.length && setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
@@ -128,13 +85,10 @@ export const FilterButton = <T,>({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{!shared && (
|
|
||||||
<FilterSheet<T>
|
<FilterSheet<T>
|
||||||
title={title}
|
title={title}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
modalRef={sheetModalRef}
|
|
||||||
loading={isLoading}
|
|
||||||
data={filters}
|
data={filters}
|
||||||
values={values}
|
values={values}
|
||||||
set={set}
|
set={set}
|
||||||
@@ -143,7 +97,6 @@ export const FilterButton = <T,>({
|
|||||||
disableSearch={disableSearch}
|
disableSearch={disableSearch}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useDeferredValue,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -26,21 +19,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { Loader } from "../Loader";
|
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
interface Props<T> extends ViewProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
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;
|
data?: T[] | null;
|
||||||
/** True while the options are loading — shows a loader inside the sheet. */
|
|
||||||
loading?: boolean;
|
|
||||||
values: T[];
|
values: T[];
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -83,18 +66,16 @@ const LIMIT = 100;
|
|||||||
export const FilterSheet = <T,>({
|
export const FilterSheet = <T,>({
|
||||||
values,
|
values,
|
||||||
data: _data,
|
data: _data,
|
||||||
loading = false,
|
|
||||||
open,
|
open,
|
||||||
set,
|
set,
|
||||||
setOpen,
|
setOpen,
|
||||||
modalRef,
|
|
||||||
title,
|
title,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
disableSearch = false,
|
disableSearch = false,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const bottomSheetModalRef = modalRef;
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
const snapPoints = useMemo(() => ["85%"], []);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -103,24 +84,19 @@ export const FilterSheet = <T,>({
|
|||||||
const [offset, setOffset] = useState<number>(0);
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
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 [showSearch, setShowSearch] = useState<boolean>(false);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!deferredSearch) return _data;
|
if (!search) return _data;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
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]);
|
results.push(_data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.slice(0, 100);
|
return results.slice(0, 100);
|
||||||
}, [deferredSearch, _data, searchFilter]);
|
}, [search, _data, searchFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
if (!data || data.length === 0 || disableSearch) return;
|
||||||
@@ -151,28 +127,21 @@ export const FilterSheet = <T,>({
|
|||||||
setData(newData);
|
setData(newData);
|
||||||
}, [offset, _data]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open && wasPresentedRef.current) {
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
bottomSheetModalRef.current?.dismiss();
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
}
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
wasPresentedRef.current = true;
|
|
||||||
} else if (index === -1) {
|
|
||||||
wasPresentedRef.current = false;
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
const renderData = useMemo(() => {
|
||||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
if (search.length > 0 && showSearch) return filteredData;
|
||||||
return data;
|
return data;
|
||||||
}, [deferredSearch, showSearch, filteredData, data]);
|
}, [search, filteredData, data]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(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 (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
@@ -261,15 +182,9 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
<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'>
|
<Text className='mb-2 text-neutral-500'>
|
||||||
{t("search.x_items", { count: _data?.length })}
|
{t("search.x_items", { count: _data?.length })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
@@ -288,7 +203,43 @@ export const FilterSheet = <T,>({
|
|||||||
}}
|
}}
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
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>
|
</View>
|
||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type PropsWithChildren,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
|
|
||||||
|
|
||||||
interface FilterSheetContextType {
|
|
||||||
openFilter: (config: FilterConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shared-sheet controller, or null when rendered outside a
|
|
||||||
* FilterSheetProvider — FilterButton then falls back to its own standalone
|
|
||||||
* sheet (used by screens that don't host a provider, e.g. logs / discover).
|
|
||||||
*/
|
|
||||||
export const useFilterSheet = (): FilterSheetContextType | null =>
|
|
||||||
useContext(FilterSheetContext);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hosts the single shared filter sheet for a screen. Every FilterButton under
|
|
||||||
* it calls openFilter() to show its options in that one sheet — so two sheets
|
|
||||||
* can never stack regardless of how fast the buttons are tapped. present() runs
|
|
||||||
* synchronously from the button's press handler (the modal is always mounted).
|
|
||||||
*/
|
|
||||||
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const modalRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [config, setConfig] = useState<FilterConfig | null>(null);
|
|
||||||
|
|
||||||
// First-wins guard. With a single shared sheet there is exactly one source of
|
|
||||||
// truth (this ref) reset on the one close path — so unlike a per-button guard
|
|
||||||
// it can't get stuck on remounts or multiple instances. A second tap during
|
|
||||||
// the first sheet's open animation is ignored; the first tapped filter wins.
|
|
||||||
const openRef = useRef(false);
|
|
||||||
|
|
||||||
const openFilter = useCallback((next: FilterConfig) => {
|
|
||||||
if (openRef.current) return;
|
|
||||||
openRef.current = true;
|
|
||||||
setConfig(next);
|
|
||||||
setOpen(true);
|
|
||||||
modalRef.current?.present();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Single close path for every dismissal (select / swipe / backdrop) — frees
|
|
||||||
// the guard reliably.
|
|
||||||
const closeSheet = useCallback(() => {
|
|
||||||
openRef.current = false;
|
|
||||||
setOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ openFilter }), [openFilter]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FilterSheetContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
<SharedFilterSheet
|
|
||||||
modalRef={modalRef}
|
|
||||||
open={open}
|
|
||||||
setOpen={closeSheet}
|
|
||||||
config={config}
|
|
||||||
/>
|
|
||||||
</FilterSheetContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,24 +1,38 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import { useFilterReset } from "@/hooks/useFilterReset";
|
import {
|
||||||
|
filterByAtom,
|
||||||
|
genreFilterAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {}
|
||||||
libraryId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ResetFiltersButton: React.FC<Props> = ({
|
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||||
libraryId,
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
...props
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
}) => {
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
|
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||||
|
|
||||||
if (!hasActiveFilters) {
|
if (
|
||||||
|
selectedGenres.length === 0 &&
|
||||||
|
selectedTags.length === 0 &&
|
||||||
|
selectedYears.length === 0 &&
|
||||||
|
selectedFilters.length === 0
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={resetAllFilters}
|
onPress={() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedFilters([]);
|
||||||
|
}}
|
||||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { isEqual } from "lodash";
|
|
||||||
import type React from "react";
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useDeferredValue,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
|
||||||
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";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Config for the filter currently shown by the shared sheet. Generics are erased
|
|
||||||
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
|
|
||||||
*/
|
|
||||||
export interface FilterConfig {
|
|
||||||
/** Stable identity — changing it remounts the content with fresh state. */
|
|
||||||
key: string;
|
|
||||||
id: string;
|
|
||||||
queryKey: string;
|
|
||||||
queryFn: (params: any) => Promise<any>;
|
|
||||||
title: string;
|
|
||||||
values: any[];
|
|
||||||
set: (value: any[]) => void;
|
|
||||||
renderItemLabel: (item: any) => React.ReactNode;
|
|
||||||
searchFilter?: (item: any, query: string) => boolean;
|
|
||||||
disableSearch?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIMIT = 100;
|
|
||||||
|
|
||||||
interface SharedFilterSheetProps {
|
|
||||||
modalRef: React.RefObject<BottomSheetModal | null>;
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
config: FilterConfig | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single shared filter sheet — one BottomSheetModal hosted by
|
|
||||||
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
|
|
||||||
* Because only one modal ever exists, rapid taps across buttons can never stack
|
|
||||||
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
|
|
||||||
* stable ref (present() can run synchronously from the tapping button); the
|
|
||||||
* inner content is keyed by the active filter so its pagination/search reset
|
|
||||||
* cleanly between filters.
|
|
||||||
*/
|
|
||||||
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
|
|
||||||
modalRef,
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
config,
|
|
||||||
}) => {
|
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// Opening is imperative (the provider calls present()); this effect only
|
|
||||||
// closes, and never dismisses a modal that was never presented.
|
|
||||||
const wasPresentedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open && wasPresentedRef.current) {
|
|
||||||
modalRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [open, modalRef]);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index >= 0) {
|
|
||||||
wasPresentedRef.current = true;
|
|
||||||
} else if (index === -1) {
|
|
||||||
wasPresentedRef.current = false;
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestClose = useCallback(() => setOpen(false), [setOpen]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={modalRef}
|
|
||||||
index={0}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView style={{ flex: 1 }}>
|
|
||||||
<View
|
|
||||||
className='mt-2 mb-8'
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config && (
|
|
||||||
<SharedFilterSheetContent
|
|
||||||
key={config.key}
|
|
||||||
config={config}
|
|
||||||
onRequestClose={requestClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SharedFilterSheetContentProps {
|
|
||||||
config: FilterConfig;
|
|
||||||
onRequestClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
|
|
||||||
config,
|
|
||||||
onRequestClose,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
queryKey,
|
|
||||||
queryFn,
|
|
||||||
title,
|
|
||||||
values,
|
|
||||||
set,
|
|
||||||
renderItemLabel,
|
|
||||||
searchFilter,
|
|
||||||
disableSearch = false,
|
|
||||||
multiple = false,
|
|
||||||
} = config;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// The options query lives here (deduped with the FilterButton's own query via
|
|
||||||
// the shared React Query key), so the list stays live after the sheet opens.
|
|
||||||
const { data: _data, isLoading: loading } = useQuery<any[]>({
|
|
||||||
queryKey: ["filters", title, queryKey, id],
|
|
||||||
queryFn,
|
|
||||||
staleTime: 0,
|
|
||||||
enabled: !!id && !!queryFn && !!queryKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [data, setData] = useState<any[]>([]);
|
|
||||||
const [offset, setOffset] = useState(0);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
// Filtering on every keystroke blocks the JS thread on large lists; defer the
|
|
||||||
// value so the keystroke render stays cheap and the list update runs after.
|
|
||||||
const deferredSearch = useDeferredValue(search);
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!deferredSearch) return _data;
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
|
||||||
if (_data && searchFilter?.(_data[i], deferredSearch)) {
|
|
||||||
results.push(_data[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results.slice(0, 100);
|
|
||||||
}, [deferredSearch, _data, searchFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
|
||||||
if (data.length > 15) {
|
|
||||||
setShowSearch(true);
|
|
||||||
}
|
|
||||||
}, [data, disableSearch]);
|
|
||||||
|
|
||||||
// Loads data in batches of LIMIT from offset (efficient "load more").
|
|
||||||
useEffect(() => {
|
|
||||||
if (!_data || _data.length === 0) return;
|
|
||||||
|
|
||||||
const newData = [...data];
|
|
||||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
|
||||||
const item = _data[i];
|
|
||||||
// Option objects are recreated across renders → dedupe by value.
|
|
||||||
const exists = newData.some((existingItem) =>
|
|
||||||
isEqual(existingItem, item),
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
newData.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setData(newData);
|
|
||||||
}, [offset, _data]);
|
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
|
||||||
if (deferredSearch.length > 0 && showSearch) return filteredData;
|
|
||||||
return data;
|
|
||||||
}, [deferredSearch, showSearch, filteredData, data]);
|
|
||||||
|
|
||||||
const renderedRows = useMemo(
|
|
||||||
() =>
|
|
||||||
renderData?.map((item, index) => (
|
|
||||||
<View key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
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(() => onRequestClose(), 250);
|
|
||||||
} else if (!isSelected) {
|
|
||||||
set([item]);
|
|
||||||
setTimeout(() => onRequestClose(), 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, renderItemLabel, onRequestClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{showSearch && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
className='my-2 border-neutral-800 border'
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
returnKeyType='done'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{ borderRadius: 20, overflow: "hidden" }}
|
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{renderedRows}
|
|
||||||
</View>
|
|
||||||
{data.length < (_data?.length || 0) && (
|
|
||||||
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import {
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
|
||||||
pendingAccountSaveAtom,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -93,9 +89,6 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
// Gate the intro so it can't steal presentation from the post-login
|
|
||||||
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
|
||||||
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -104,9 +97,7 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
if (!hasShownIntro) {
|
||||||
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
|
||||||
if (!hasShownIntro && !pendingAccountSave) {
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -115,7 +106,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro, pendingAccountSave]);
|
}, [showIntro]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
@@ -86,7 +85,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
}, [isSuccess, onLoaded]);
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
@@ -188,10 +186,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster item={item} />
|
||||||
item={item}
|
|
||||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
@@ -166,7 +165,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
const allItems = useMemo(() => {
|
||||||
const items = data?.pages.flat() ?? [];
|
const items = data?.pages.flat() ?? [];
|
||||||
@@ -203,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleSeeAllPress = useCallback(() => {
|
const handleSeeAllPress = useCallback(() => {
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
|
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
||||||
|
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
||||||
|
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
||||||
|
// (b) intercept Back to route to the library list so the user can switch
|
||||||
|
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
pathname: "/[libraryId]",
|
||||||
params: {
|
params: {
|
||||||
libraryId: parentId,
|
libraryId: parentId,
|
||||||
sortBy: SortByOption.DateCreated,
|
sortBy: SortByOption.DateCreated,
|
||||||
sortOrder: SortOrderOption.Descending,
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
fromSeeAll: "true",
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
@@ -227,7 +231,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -240,7 +243,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
settings?.useEpisodeImagesForNextUp,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
|
// No fixed width: the footer must size to the "See All" card so the
|
||||||
|
// FlatList's scrollable content extends to fully reveal it. A fixed
|
||||||
|
// (narrow) width clipped the card at the right edge. Trailing space is
|
||||||
|
// provided by contentContainerStyle.paddingRight.
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: sizes.padding.horizontal,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -51,7 +50,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
@@ -110,10 +108,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster
|
<ContinueWatchingPoster item={item} />
|
||||||
item={item}
|
|
||||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
|
|||||||
@@ -65,11 +65,10 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
const posterUrl = useMemo(() => {
|
const posterUrl = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
// For episodes, always use series thumb.
|
// For episodes, always use series thumb
|
||||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
if (item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
if (item.SeriesId) {
|
if (item.SeriesId) {
|
||||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
|||||||
@@ -23,11 +23,9 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
{title ? (
|
|
||||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -34,17 +34,12 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
// Keep the row floor uniform; Android trims padding slightly (its native
|
|
||||||
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
|
||||||
// rows match non-toggle rows.
|
|
||||||
const rowSizing =
|
|
||||||
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -63,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -23,7 +22,6 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -70,7 +68,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.live")}
|
LIVE
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("live_tv.title")}
|
Live TV
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,16 +20,14 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import {
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
apiAtom,
|
import type {
|
||||||
pendingAccountSaveAtom,
|
AccountSecurityType,
|
||||||
useJellyfin,
|
SavedServer,
|
||||||
userAtom,
|
} from "@/utils/secureCredentials";
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import type { SavedServer } from "@/utils/secureCredentials";
|
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -39,17 +37,14 @@ export const Login: React.FC = () => {
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const {
|
const {
|
||||||
setServer,
|
setServer,
|
||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -69,43 +64,13 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quick Connect code shown in the in-app sheet while polling for authorization
|
// Save account state
|
||||||
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.
|
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
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
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,34 +117,55 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
const ok = await performLogin(credentials.username, credentials.password);
|
if (saveAccount) {
|
||||||
// The protection picker shows AFTER a successful login (global modal) —
|
setPendingLogin({
|
||||||
// never for a failed one.
|
username: credentials.username,
|
||||||
if (ok && saveAccount) {
|
password: credentials.password,
|
||||||
setPendingAccountSave({ serverName });
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<boolean> => {
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName);
|
await login(username, password, serverName, options);
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
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) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -408,7 +402,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label={t("server.server_url")}
|
aria-label='Server URL'
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -450,11 +444,14 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Dismissing only hides the code — polling continues so the login still
|
<SaveAccountModal
|
||||||
completes if the code is authorized from another device afterwards. */}
|
visible={showSaveModal}
|
||||||
<QuickConnectCodeModal
|
onClose={() => {
|
||||||
code={quickConnectCode}
|
setShowSaveModal(false);
|
||||||
onClose={() => setQuickConnectCode(null)}
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</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 {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occured"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occurred");
|
: t("login.an_unexpected_error_occured");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -89,8 +88,6 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +101,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.library")}
|
label='Library'
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label={t("search.discover")}
|
label='Discover'
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking } from "react-native";
|
import { Linking, Switch } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -28,7 +27,6 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -36,7 +34,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -46,51 +44,31 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.use_episode_images_next_up")}
|
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.use_episode_images_next_up_hint",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingSwitch
|
|
||||||
value={settings.useEpisodeImagesForNextUp}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.hide_remote_session_button_hint",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SettingSwitch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
}
|
}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -10,7 +9,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch } from "react-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
// Only the server URL is admin-lockable — the password stays editable so
|
|
||||||
// the user can still sign in to the admin-pinned Jellyseerr server.
|
|
||||||
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -118,7 +115,6 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -132,11 +128,7 @@ export const JellyseerrSettings = () => {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={
|
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||||
urlLocked
|
|
||||||
? settings?.jellyseerrServerUrl
|
|
||||||
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
|
||||||
}
|
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -145,14 +137,8 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
{urlLocked && (
|
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
|
||||||
Disabled by admin
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
import { Switch, Text, View } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup>
|
<View className=''>
|
||||||
<ListItem
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
disabledByAdmin={locked}
|
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
>
|
</Text>
|
||||||
<SettingSwitch
|
|
||||||
|
<View className='flex flex-row items-center justify-between mt-2'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Switch
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
disabled={locked}
|
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
|
trackColor={{ false: "#555", true: "purple" }}
|
||||||
|
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</View>
|
||||||
</ListGroup>
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Switch, TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -148,10 +147,7 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
title={t("home.settings.network.auto_switch_enabled")}
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
subtitle={t("home.settings.network.auto_switch_description")}
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
||||||
value={config.enabled}
|
|
||||||
onValueChange={handleToggleEnabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -19,21 +17,20 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: t("home.settings.subtitles.align.left"),
|
left: "Left",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
right: t("home.settings.subtitles.align.right"),
|
right: "Right",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: t("home.settings.subtitles.align.top"),
|
top: "Top",
|
||||||
center: t("home.settings.subtitles.align.center"),
|
center: "Center",
|
||||||
bottom: t("home.settings.subtitles.align.bottom"),
|
bottom: "Bottom",
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -63,18 +60,16 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
title='MPV Subtitle Settings'
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
{t("home.settings.subtitles.mpv_settings_description")}
|
Advanced subtitle customization for MPV player
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem title='Vertical Margin'>
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
|
||||||
>
|
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -86,7 +81,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
<ListItem title='Horizontal Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -101,11 +96,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
title='Horizontal Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
<ListItem title='Vertical Alignment'>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -120,14 +115,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
title='Vertical Alignment'
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
<ListItem title='Opaque Background'>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||||
@@ -136,7 +131,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
<ListItem title='Background Opacity'>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, View } from "react-native";
|
import { Linking, Switch, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -133,7 +132,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -151,7 +150,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -189,7 +188,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
@@ -116,7 +115,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
@@ -147,7 +146,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -206,7 +205,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
@@ -219,7 +218,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title='Jellyseerr'
|
title={"Jellyseerr"}
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
|
||||||
title='Streamystats'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Marlin Search'
|
title='Marlin Search'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/streamystats/page")}
|
||||||
|
title='Streamystats'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -6,13 +5,11 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -61,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
@@ -82,15 +79,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
const pasteCode = useCallback(async () => {
|
|
||||||
// Builds without the expo-clipboard native module: probe first (no-op).
|
|
||||||
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
|
||||||
const Clipboard = await import("expo-clipboard");
|
|
||||||
const text = await Clipboard.getStringAsync();
|
|
||||||
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
|
||||||
if (digits) setQuickConnectCode(digits);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,15 +130,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Pressable
|
|
||||||
onPress={pasteCode}
|
|
||||||
className='flex-row items-center justify-center self-center'
|
|
||||||
>
|
|
||||||
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
|
||||||
<Text className='text-neutral-400 ml-2'>
|
|
||||||
{t("home.settings.quick_connect.paste_code")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// Keep the bar moving while a download is writing to disk.
|
|
||||||
refetchInterval: 10 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = () => {
|
const onDeleteClicked = async () => {
|
||||||
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 {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
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 calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
@@ -125,7 +102,7 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
<ListGroup>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteClicked}
|
onPress={onDeleteClicked}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -98,7 +98,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
className='mb-4'
|
|
||||||
title={t("home.settings.subtitles.subtitle_title")}
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
@@ -153,7 +152,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.set_subtitle_track")}
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
>
|
>
|
||||||
<SettingSwitch
|
<Switch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export interface TVNextEpisodeCountdownProps {
|
|||||||
playButtonRef?: RNView | null;
|
playButtonRef?: RNView | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants
|
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
||||||
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
||||||
|
// rationale (220 sits just above the controls bar; 300 floated too high).
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -70,9 +69,6 @@ export interface TVPosterCardProps {
|
|||||||
|
|
||||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
|
||||||
/** For horizontal episodes, prefer the episode's own image over the series thumb */
|
|
||||||
preferEpisodeImage?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,9 +105,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
glowColor = "white",
|
glowColor = "white",
|
||||||
scaleAmount = 1.05,
|
scaleAmount = 1.05,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
preferEpisodeImage = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
@@ -142,14 +136,9 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
if (orientation === "horizontal") {
|
if (orientation === "horizontal") {
|
||||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
// Opt-in: use the episode's own image instead of the series thumb.
|
// First try parent/series thumb (horizontal series artwork)
|
||||||
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
|
||||||
// First try parent/series thumb (horizontal series artwork).
|
|
||||||
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
|
|
||||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
|
||||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
}
|
||||||
// Fall back to episode's own primary image
|
// Fall back to episode's own primary image
|
||||||
if (item.ImageTags?.Primary) {
|
if (item.ImageTags?.Primary) {
|
||||||
@@ -181,7 +170,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
item,
|
item,
|
||||||
width: width * 2, // 2x for quality on large screens
|
width: width * 2, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
}, [api, item, orientation, width, imageUrlGetter]);
|
||||||
|
|
||||||
// Progress calculation
|
// Progress calculation
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
@@ -382,7 +371,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("music.now_playing")}
|
Now Playing
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ export interface TVSkipSegmentCardProps {
|
|||||||
playButtonRef?: View | null;
|
playButtonRef?: View | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
||||||
const BOTTOM_WITH_CONTROLS = 300;
|
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
||||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
// card that floats far above the controls.
|
||||||
|
//
|
||||||
|
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
|
||||||
|
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
|
||||||
|
// left the card hovering ~100px above the controls.
|
||||||
|
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||||
|
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||||
|
|
||||||
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||||
show,
|
show,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -29,7 +28,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<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>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
30
components/video-player/VideoPlayerView.tsx
Normal file
30
components/video-player/VideoPlayerView.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
|
||||||
|
import { MpvPlayerView } from "@/modules";
|
||||||
|
import { ExoPlayerView } from "@/modules/exoplayer-player";
|
||||||
|
import {
|
||||||
|
getActiveVideoPlayer,
|
||||||
|
useSettings,
|
||||||
|
VideoPlayer,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified video player view. MPV is the default on every platform; users
|
||||||
|
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
|
||||||
|
* children conform to the same `MpvPlayerViewRef` interface, so the ref
|
||||||
|
* is forwarded transparently regardless of which player is rendered.
|
||||||
|
*
|
||||||
|
* The Android-TV capability gate lives in getActiveVideoPlayer so that
|
||||||
|
* the same resolver used for device-profile advertisement guarantees the
|
||||||
|
* rendered backend matches what Jellyfin was told to stream for.
|
||||||
|
*/
|
||||||
|
export const VideoPlayerView = React.forwardRef<
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
MpvPlayerViewProps
|
||||||
|
>(function VideoPlayerView(props, ref) {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||||
|
|
||||||
|
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||||
|
return <Player ref={ref} {...props} />;
|
||||||
|
});
|
||||||
@@ -183,7 +183,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText={t("player.skip_intro")}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- 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)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={t("player.skip_credits")}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
{t("player.still_watching")}
|
Are you still watching ?
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -1129,7 +1129,16 @@ export const Controls: FC<Props> = ({
|
|||||||
{/* Skip intro card */}
|
{/* Skip intro card */}
|
||||||
<TVSkipSegmentCard
|
<TVSkipSegmentCard
|
||||||
show={showSkipButton && !isCountdownActive}
|
show={showSkipButton && !isCountdownActive}
|
||||||
onPress={skipIntro}
|
onPress={() => {
|
||||||
|
// After the seek lands, showSkipButton flips false and this card
|
||||||
|
// unmounts. With controls visible the focus-stealing overlay is
|
||||||
|
// disabled, so without an explicit handoff the focus engine is
|
||||||
|
// stranded. Prime the play button to receive focus on the next
|
||||||
|
// render — when controls are hidden the focus overlay takes over
|
||||||
|
// naturally and this is a harmless no-op.
|
||||||
|
if (showControls) setFocusPlayButton(true);
|
||||||
|
skipIntro();
|
||||||
|
}}
|
||||||
type='intro'
|
type='intro'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
@@ -1144,7 +1153,11 @@ export const Controls: FC<Props> = ({
|
|||||||
(hasContentAfterCredits || !nextItem) &&
|
(hasContentAfterCredits || !nextItem) &&
|
||||||
!isCountdownActive
|
!isCountdownActive
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={() => {
|
||||||
|
// See the intro card above for the focus-handoff rationale.
|
||||||
|
if (showControls) setFocusPlayButton(true);
|
||||||
|
skipCredit();
|
||||||
|
}}
|
||||||
type='credits'
|
type='credits'
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
refSetter={setSkipSegmentRef}
|
refSetter={setSkipSegmentRef}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -58,7 +57,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -129,8 +127,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
accessibilityLabel='Toggle screen orientation'
|
||||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -185,7 +184,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -215,13 +213,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
container: mediaSource.Container,
|
|
||||||
videoRange: videoStream?.VideoRangeType,
|
videoRange: videoStream?.VideoRangeType,
|
||||||
bitDepth: videoStream?.BitDepth,
|
bitDepth: videoStream?.BitDepth,
|
||||||
audioChannels: audioStream?.Channels,
|
audioChannels: audioStream?.Channels,
|
||||||
audioCodecFromSource: audioStream?.Codec,
|
|
||||||
subtitleCodec: subtitleStream?.Codec,
|
subtitleCodec: subtitleStream?.Codec,
|
||||||
subtitleTitle: subtitleStream?.DisplayTitle,
|
|
||||||
};
|
};
|
||||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||||
|
|
||||||
@@ -307,34 +302,47 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{info.videoWidth}x{info.videoHeight}
|
{info.videoWidth}x{info.videoHeight}
|
||||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||||
{formatVideoRange(streamInfo?.videoRange)
|
{/* Prefer the player-reported HDR format (authoritative —
|
||||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
what's actually being decoded) over Jellyfin metadata. */}
|
||||||
: ""}
|
{info?.hdrFormat
|
||||||
|
? ` ${info.hdrFormat}`
|
||||||
|
: (() => {
|
||||||
|
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
||||||
|
return videoRange ? ` ${videoRange}` : "";
|
||||||
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
Video: {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
Audio: {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{/* Prefer player-reported channel count; fall back to
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
Jellyfin metadata for MPV which doesn't populate it. */}
|
||||||
|
{(() => {
|
||||||
|
const audioChannels =
|
||||||
|
info.audioChannels ?? streamInfo?.audioChannels;
|
||||||
|
return audioChannels
|
||||||
|
? ` ${formatAudioChannels(audioChannels)}`
|
||||||
|
: "";
|
||||||
|
})()}
|
||||||
|
{info.audioSampleRate
|
||||||
|
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||||
: ""}
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.subtitle")}{" "}
|
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||||
{formatCodec(streamInfo.subtitleCodec)}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.bitrate")}{" "}
|
Bitrate:{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -342,11 +350,20 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Color:{" "}
|
||||||
|
{[info.colorSpace, info.colorRange, info.colorTransfer]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{info?.videoCodecs && (
|
||||||
|
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
|
||||||
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.buffer_seconds", {
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
seconds: info.cacheSeconds.toFixed(1),
|
|
||||||
})}
|
|
||||||
{info?.demuxerMaxBytes !== undefined
|
{info?.demuxerMaxBytes !== undefined
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
@@ -357,10 +374,16 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
{t("player.technical_info.vo")} {info.voDriver}
|
VO: {info.voDriver}
|
||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{info?.decoderName && (
|
||||||
|
<Text style={textStyle}>
|
||||||
|
Decoder: {info.decoderName}
|
||||||
|
{info.decoderType ? ` (${info.decoderType})` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{info?.estimatedVfFps !== undefined && (
|
{info?.estimatedVfFps !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||||
@@ -369,14 +392,10 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
{t("player.technical_info.dropped_frames", {
|
Dropped: {info.droppedFrames} frames
|
||||||
count: info.droppedFrames,
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && (
|
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -55,7 +54,6 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -68,10 +66,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label:
|
label: option.label,
|
||||||
option.id === "default"
|
|
||||||
? t("player.aspect_ratio_original")
|
|
||||||
: option.label,
|
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -99,7 +94,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.aspect_ratio")}
|
title='Aspect Ratio'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -103,7 +101,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.quality"),
|
title: "Quality",
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -118,7 +116,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitles"),
|
title: "Subtitles",
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -130,7 +128,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.subtitle_scale"),
|
title: "Subtitle Scale",
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -144,7 +142,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.audio"),
|
title: "Audio",
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -158,7 +156,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: t("player.menu.speed"),
|
title: "Speed",
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -176,8 +174,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? t("player.menu.hide_technical_info")
|
? "Hide Technical Info"
|
||||||
: t("player.menu.show_technical_info"),
|
: "Show Technical Info",
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -187,7 +185,6 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
t,
|
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -220,7 +217,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title={t("player.menu.playback_options")}
|
title='Playback Options'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
import i18n from "@/i18n";
|
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -125,23 +124,17 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
i18n.t("player.stopPlayback"),
|
"Stop Playback",
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? i18n.t("player.stopPlayingTitle", {
|
? `Stop playing "${videoTitleRef.current}"?`
|
||||||
title: videoTitleRef.current,
|
: "Are you sure you want to stop playback?",
|
||||||
})
|
|
||||||
: i18n.t("player.stopPlayingConfirm"),
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: i18n.t("common.cancel"),
|
text: "Cancel",
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{
|
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||||
text: i18n.t("common.stop"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: onBackRef.current,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
export default {
|
const MediaTypes = {
|
||||||
Audio: "Audio",
|
Audio: "Audio",
|
||||||
Video: "Video",
|
Video: "Video",
|
||||||
Photo: "Photo",
|
Photo: "Photo",
|
||||||
Book: "Book",
|
Book: "Book",
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||||
|
|
||||||
|
export default MediaTypes;
|
||||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
|||||||
|
|
||||||
Relevant files:
|
Relevant files:
|
||||||
|
|
||||||
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
||||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||||
|
|||||||
@@ -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 { useRouter } from "expo-router";
|
||||||
import { NavigationContext } from "expo-router/react-navigation";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useCallback, useContext, useMemo } from "react";
|
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop-in replacement for expo-router's useRouter that automatically
|
* Drop-in replacement for expo-router's useRouter that automatically
|
||||||
* preserves offline state across navigation and guards against duplicate
|
* preserves offline state across navigation.
|
||||||
* screens from rapid taps.
|
|
||||||
*
|
*
|
||||||
* - For object-form navigation, automatically adds offline=true when in offline context
|
* - For object-form navigation, automatically adds offline=true when in offline context
|
||||||
* - For string URLs, passes through unchanged (caller handles offline param)
|
* - 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
|
* @example
|
||||||
* import useRouter from "@/hooks/useAppRouter";
|
* import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -25,18 +19,8 @@ export function useAppRouter() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
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(
|
const push = useCallback(
|
||||||
(href: Parameters<typeof router.push>[0]) => {
|
(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") {
|
if (typeof href === "string") {
|
||||||
router.push(href as any);
|
router.push(href as any);
|
||||||
} else {
|
} else {
|
||||||
@@ -52,7 +36,7 @@ export function useAppRouter() {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, isOffline, navigation],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const replace = useCallback(
|
const replace = useCallback(
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import {
|
|
||||||
FilterByPreferenceAtom,
|
|
||||||
filterByAtom,
|
|
||||||
genreFilterAtom,
|
|
||||||
genrePreferenceAtom,
|
|
||||||
SortByOption,
|
|
||||||
SortOrderOption,
|
|
||||||
sortByAtom,
|
|
||||||
sortByPreferenceAtom,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderPreferenceAtom,
|
|
||||||
tagPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
yearPreferenceAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the library filter bar's "reset" action and its
|
|
||||||
* visibility. The mobile ResetFiltersButton and the TV filter header both use
|
|
||||||
* this so they can't drift — sort/order used to be reset on neither path, so
|
|
||||||
* the reset (X) never reflected a changed sort.
|
|
||||||
*
|
|
||||||
* A reset clears the session filters AND the per-library in-memory preferences
|
|
||||||
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
|
|
||||||
* resurfaces when the library's mount effect re-applies it on the next entry.
|
|
||||||
*/
|
|
||||||
export const useFilterReset = (libraryId: string) => {
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
|
||||||
const [filterBy, setFilterBy] = useAtom(filterByAtom);
|
|
||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
|
||||||
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
|
||||||
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
|
|
||||||
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
|
|
||||||
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
|
|
||||||
const [, setYearPreference] = useAtom(yearPreferenceAtom);
|
|
||||||
const [, setTagPreference] = useAtom(tagPreferenceAtom);
|
|
||||||
|
|
||||||
// SortName / Ascending is the baseline a library opens with (mount-effect
|
|
||||||
// fallback), so any other value counts as an active, resettable sort.
|
|
||||||
const hasActiveFilters =
|
|
||||||
selectedGenres.length > 0 ||
|
|
||||||
selectedYears.length > 0 ||
|
|
||||||
selectedTags.length > 0 ||
|
|
||||||
filterBy.length > 0 ||
|
|
||||||
sortBy[0] !== SortByOption.SortName ||
|
|
||||||
sortOrder[0] !== SortOrderOption.Ascending;
|
|
||||||
|
|
||||||
const resetAllFilters = useCallback(() => {
|
|
||||||
setSelectedGenres([]);
|
|
||||||
setSelectedYears([]);
|
|
||||||
setSelectedTags([]);
|
|
||||||
setFilterBy([]);
|
|
||||||
setSortBy([SortByOption.SortName]);
|
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
|
||||||
setSortByPreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setSortOrderPreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setFilterByPreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setGenrePreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setYearPreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setTagPreference((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[libraryId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
libraryId,
|
|
||||||
setSelectedGenres,
|
|
||||||
setSelectedYears,
|
|
||||||
setSelectedTags,
|
|
||||||
setFilterBy,
|
|
||||||
setSortBy,
|
|
||||||
setSortOrder,
|
|
||||||
setSortByPreference,
|
|
||||||
setSortOrderPreference,
|
|
||||||
setFilterByPreference,
|
|
||||||
setGenrePreference,
|
|
||||||
setYearPreference,
|
|
||||||
setTagPreference,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { hasActiveFilters, resetAllFilters };
|
|
||||||
};
|
|
||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
68
modules/exoplayer-player/android/build.gradle
Normal file
68
modules/exoplayer-player/android/build.gradle
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.exoplayerplayer'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
def useManagedAndroidSdkVersions = false
|
||||||
|
if (useManagedAndroidSdkVersions) {
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
} else {
|
||||||
|
buildscript {
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.exoplayerplayer"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Media3 (ExoPlayer). The default tracks react-native-track-player's
|
||||||
|
// pinned version (currently 1.10.1) so we don't end up with two media3
|
||||||
|
// versions on the classpath and duplicate-class errors. The
|
||||||
|
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
|
||||||
|
// globally via plugins/withExcludeMedia3Dash.js.
|
||||||
|
def media3Version = safeExtGet('media3Version', '1.10.1')
|
||||||
|
implementation "androidx.media3:media3-exoplayer:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-ui:${media3Version}"
|
||||||
|
implementation "androidx.media3:media3-common:${media3Version}"
|
||||||
|
|
||||||
|
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
|
||||||
|
// codecs that MediaCodec doesn't ship with on most Android TVs.
|
||||||
|
//
|
||||||
|
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
|
||||||
|
// with prebuilt native libraries (the upstream androidx artifact is a
|
||||||
|
// stub that requires building FFmpeg yourself). RNTP already pulls
|
||||||
|
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
|
||||||
|
// it guarantees we still get it if RNTP ever drops the dep.
|
||||||
|
//
|
||||||
|
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
|
||||||
|
// resolve media3 core to 1.10.1. RNTP ships the same combination in
|
||||||
|
// production, and Media3 maintains binary compat for Renderer /
|
||||||
|
// RenderersFactory APIs across minor versions, so this works in
|
||||||
|
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
|
||||||
|
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExoPlayerModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExoPlayer")
|
||||||
|
|
||||||
|
// Enables the module to be used as a native view.
|
||||||
|
View(ExoPlayerView::class) {
|
||||||
|
// All video load options are passed via a single "source" prop,
|
||||||
|
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
|
||||||
|
// cacheConfig fields) are silently ignored.
|
||||||
|
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
|
||||||
|
if (source == null) return@Prop
|
||||||
|
|
||||||
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||||
|
|
||||||
|
val config = VideoLoadConfig(
|
||||||
|
url = urlString,
|
||||||
|
headers = source["headers"] as? Map<String, String>,
|
||||||
|
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||||
|
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||||
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
|
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||||
|
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||||
|
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
view.loadVideo(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
|
||||||
|
// no Control Center equivalent — Android handles media sessions
|
||||||
|
// via MediaSessionCompat which we don't wire up for TV).
|
||||||
|
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("play") { view: ExoPlayerView ->
|
||||||
|
view.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pause") { view: ExoPlayerView ->
|
||||||
|
view.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("destroy") { view: ExoPlayerView ->
|
||||||
|
view.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
|
||||||
|
view.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
|
||||||
|
view.seekBy(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
|
||||||
|
view.setSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getSpeed") { view: ExoPlayerView ->
|
||||||
|
view.getSpeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPaused") { view: ExoPlayerView ->
|
||||||
|
view.isPaused()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getDuration") { view: ExoPlayerView ->
|
||||||
|
view.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picture in Picture — TV does not use PiP; safe no-ops.
|
||||||
|
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle functions
|
||||||
|
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
|
||||||
|
view.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setSubtitleTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
|
||||||
|
view.disableSubtitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentSubtitleTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
|
||||||
|
view.addSubtitleFile(url, select)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle positioning / styling
|
||||||
|
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
|
||||||
|
view.setSubtitlePosition(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
|
||||||
|
view.setSubtitleScale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
|
||||||
|
view.setSubtitleMarginY(margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — SubtitleView follows authored cue alignment.
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
|
||||||
|
view.setSubtitleAlignY(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
|
||||||
|
view.setSubtitleFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
|
||||||
|
view.setSubtitleBorderStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
|
||||||
|
view.setSubtitleBackgroundColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
|
||||||
|
// No-op — libass-specific, no Media3 equivalent.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track functions
|
||||||
|
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
|
||||||
|
view.getAudioTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
|
||||||
|
view.setAudioTrack(trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
|
||||||
|
view.getCurrentAudioTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video scaling
|
||||||
|
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
|
||||||
|
view.setZoomedToFill(zoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
|
||||||
|
view.isZoomedToFill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical info
|
||||||
|
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
|
||||||
|
view.getTechnicalInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events that the view can send to JavaScript — same set as MPV.
|
||||||
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
|
||||||
|
package expo.modules.exoplayerplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.ColorInfo
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.TrackSelectionOverride
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
|
||||||
|
* MPV-only fields are accepted and ignored.
|
||||||
|
*/
|
||||||
|
data class VideoLoadConfig(
|
||||||
|
val url: String,
|
||||||
|
val headers: Map<String, String>? = null,
|
||||||
|
val externalSubtitles: List<String>? = null,
|
||||||
|
val startPosition: Double? = null,
|
||||||
|
val autoplay: Boolean = true,
|
||||||
|
val initialSubtitleId: Int? = null,
|
||||||
|
val initialAudioId: Int? = null,
|
||||||
|
val cacheEnabled: String? = null,
|
||||||
|
val cacheSeconds: Int? = null,
|
||||||
|
val demuxerMaxBytes: Int? = null,
|
||||||
|
val demuxerMaxBackBytes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
|
||||||
|
*
|
||||||
|
* Implements the same JS contract (events, ref methods, 1-based track IDs)
|
||||||
|
* as MpvPlayerView so the React layer can swap between the two without
|
||||||
|
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
|
||||||
|
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
|
||||||
|
*/
|
||||||
|
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ExoPlayerView"
|
||||||
|
private const val PROGRESS_INTERVAL_MS = 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event dispatchers — names must match the Events() declaration in the module.
|
||||||
|
val onLoad by EventDispatcher()
|
||||||
|
val onPlaybackStateChange by EventDispatcher()
|
||||||
|
val onProgress by EventDispatcher()
|
||||||
|
val onError by EventDispatcher()
|
||||||
|
val onTracksReady by EventDispatcher()
|
||||||
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private val playerView: PlayerView
|
||||||
|
private val subtitleView: SubtitleView?
|
||||||
|
|
||||||
|
private var currentUrl: String? = null
|
||||||
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
|
private var tracksReadyFired: Boolean = false
|
||||||
|
|
||||||
|
// Side-loaded subtitle configurations accumulated across loadVideo and
|
||||||
|
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
|
||||||
|
// list on a playing MediaItem, so we shadow it here to preserve prior
|
||||||
|
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
|
||||||
|
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||||
|
|
||||||
|
// 1-based track ID mappings (matching MPV's contract).
|
||||||
|
// Each list is rebuilt on Tracks changed.
|
||||||
|
private var subtitleTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var audioTrackList: List<TrackEntry> = emptyList()
|
||||||
|
private var currentSubtitleId: Int = 0
|
||||||
|
private var currentAudioId: Int = 0
|
||||||
|
|
||||||
|
// Subtitle styling state — applied to the embedded SubtitleView.
|
||||||
|
private var subtitleScale: Float = 1f
|
||||||
|
private var subtitleFontSizePct: Int? = null // 0-100
|
||||||
|
// Last-write-wins override of the vertical position fraction
|
||||||
|
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
|
||||||
|
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
|
||||||
|
// (px) funnel into this single SubtitleView API.
|
||||||
|
private var subtitleBottomFraction: Float? = null
|
||||||
|
private var subtitleAlignY: String = "bottom"
|
||||||
|
// Background color carries its own alpha (parsed from #RRGGBBAA in
|
||||||
|
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
|
||||||
|
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
|
||||||
|
private var subtitleBorderStyle: String = "outline-and-shadow"
|
||||||
|
|
||||||
|
private var isZoomedToFill: Boolean = false
|
||||||
|
|
||||||
|
// Captured by analyticsListener; surfaced via getTechnicalInfo().
|
||||||
|
// Reset on destroy() and (for decoder names) on track changes.
|
||||||
|
private var videoDecoderName: String? = null
|
||||||
|
private var audioDecoderName: String? = null
|
||||||
|
private var cumulativeDroppedFrames: Int = 0
|
||||||
|
|
||||||
|
private val analyticsListener = object : AnalyticsListener {
|
||||||
|
override fun onVideoDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
videoDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDecoderInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
decoderName: String,
|
||||||
|
initializedTimestampMs: Long,
|
||||||
|
) {
|
||||||
|
audioDecoderName = decoderName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDroppedVideoFrames(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
droppedFrames: Int,
|
||||||
|
elapsedMs: Long,
|
||||||
|
) {
|
||||||
|
// Incremental count since last call; accumulate for a cumulative
|
||||||
|
// total that matches MPV's droppedFrames semantics.
|
||||||
|
cumulativeDroppedFrames += droppedFrames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val playerListener = object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
onPlaybackStateChange(mapOf("isLoading" to true))
|
||||||
|
}
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isLoading" to false,
|
||||||
|
"isReadyToSeek" to true
|
||||||
|
))
|
||||||
|
if (!tracksReadyFired) {
|
||||||
|
tracksReadyFired = true
|
||||||
|
rebuildTrackMaps(player?.currentTracks)
|
||||||
|
onTracksReady(emptyMap<String, Any>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to false,
|
||||||
|
"isPaused" to true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
onPlaybackStateChange(mapOf(
|
||||||
|
"isPlaying" to isPlaying,
|
||||||
|
"isPaused" to !isPlaying
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
|
||||||
|
val message = error?.message ?: "Unknown playback error"
|
||||||
|
Log.e(TAG, "Player error: $message", error)
|
||||||
|
onError(mapOf("error" to message))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(tracks: Tracks) {
|
||||||
|
rebuildTrackMaps(tracks)
|
||||||
|
applyInitialTrackSelections()
|
||||||
|
// A track change can re-initialize the codec under a different
|
||||||
|
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
|
||||||
|
// decoder names so getTechnicalInfo() doesn't report the
|
||||||
|
// previous codec until the next onVideoDecoderInitialized fires.
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val progressRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val p = player ?: return
|
||||||
|
val positionMs = p.currentPosition
|
||||||
|
val durationMs = p.duration
|
||||||
|
val bufferedMs = p.bufferedPosition
|
||||||
|
|
||||||
|
val positionSec = positionMs / 1000.0
|
||||||
|
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
|
||||||
|
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
|
||||||
|
|
||||||
|
onProgress(mapOf(
|
||||||
|
"position" to positionSec,
|
||||||
|
"duration" to durationSec,
|
||||||
|
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
|
||||||
|
"cacheSeconds" to cacheSec
|
||||||
|
))
|
||||||
|
|
||||||
|
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
|
playerView = PlayerView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
// SurfaceView-backed for parity with MPV (direct surface to
|
||||||
|
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
|
||||||
|
// explicit setSurfaceType() call is needed; the int constants
|
||||||
|
// backing it are @IntDef private in Media3.
|
||||||
|
setUseController(false)
|
||||||
|
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||||
|
}
|
||||||
|
subtitleView = playerView.subtitleView
|
||||||
|
addView(playerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Video Loading
|
||||||
|
|
||||||
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
|
if (currentUrl == config.url) return
|
||||||
|
currentUrl = config.url
|
||||||
|
pendingConfig = config
|
||||||
|
ensurePlayer(config)
|
||||||
|
loadInternal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensurePlayer(config: VideoLoadConfig) {
|
||||||
|
if (player != null) return
|
||||||
|
|
||||||
|
val loadControl = buildLoadControl(config)
|
||||||
|
|
||||||
|
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
|
||||||
|
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
|
||||||
|
// hardware decoder for the format. MediaCodec remains the fallback.
|
||||||
|
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
|
||||||
|
.setExtensionRendererMode(
|
||||||
|
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
|
)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||||
|
.setLoadControl(loadControl)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.build(),
|
||||||
|
/* handleAudioFocus = */ true
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
exo.addListener(playerListener)
|
||||||
|
exo.addAnalyticsListener(analyticsListener)
|
||||||
|
exo.repeatMode = Player.REPEAT_MODE_OFF
|
||||||
|
player = exo
|
||||||
|
playerView.player = exo
|
||||||
|
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
|
||||||
|
// Map MPV-style cache config to ExoPlayer's LoadControl.
|
||||||
|
val cacheEnabled = when (config.cacheEnabled) {
|
||||||
|
"no" -> false
|
||||||
|
"yes" -> true
|
||||||
|
else -> true // "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer thresholds used as fallbacks when the user's cache config
|
||||||
|
// doesn't override them. Media3's own defaults changed in 1.6.0
|
||||||
|
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
|
||||||
|
// faster start; we intentionally keep the older 2500/5000 here
|
||||||
|
// because low-RAM Android TVs with slow tuners benefit from the
|
||||||
|
// extra headroom before playback kicks in. Media3's DEFAULT_*
|
||||||
|
// IntDef fields are private, hence the literals.
|
||||||
|
val defaultMinBufferMs = 15000
|
||||||
|
val defaultBufferForPlaybackMs = 2500
|
||||||
|
val defaultBufferForPlaybackAfterRebufferMs = 5000
|
||||||
|
|
||||||
|
val targetBufferMs = if (!cacheEnabled) {
|
||||||
|
50000
|
||||||
|
} else {
|
||||||
|
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
|
||||||
|
seconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
val backBufferMs = if (!cacheEnabled) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
val mb = config.demuxerMaxBackBytes ?: 50
|
||||||
|
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
|
||||||
|
(mb * 1000).coerceAtLeast(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = DefaultLoadControl.Builder()
|
||||||
|
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
/* minBufferMs = */ defaultMinBufferMs,
|
||||||
|
/* maxBufferMs = */ targetBufferMs,
|
||||||
|
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
|
||||||
|
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
|
||||||
|
)
|
||||||
|
if (cacheEnabled) {
|
||||||
|
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInternal(config: VideoLoadConfig) {
|
||||||
|
val p = player ?: return
|
||||||
|
|
||||||
|
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
|
||||||
|
.setDefaultRequestProperties(config.headers ?: emptyMap())
|
||||||
|
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||||
|
|
||||||
|
val mediaItem = buildMediaItem(config)
|
||||||
|
|
||||||
|
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
|
.createMediaSource(mediaItem)
|
||||||
|
|
||||||
|
p.setMediaSource(mediaSource)
|
||||||
|
p.prepare()
|
||||||
|
|
||||||
|
// Apply initial playback position
|
||||||
|
config.startPosition?.let { startPosSec ->
|
||||||
|
if (startPosSec > 0) {
|
||||||
|
p.seekTo((startPosSec * 1000).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.autoplay) {
|
||||||
|
p.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(mapOf("url" to config.url))
|
||||||
|
startProgressLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
|
||||||
|
val builder = MediaItem.Builder().setUri(config.url)
|
||||||
|
|
||||||
|
// External subtitles: add as side-loaded SubtitleConfigurations.
|
||||||
|
// MIME-type sniffed from the file extension.
|
||||||
|
val subs = config.externalSubtitles
|
||||||
|
if (!subs.isNullOrEmpty()) {
|
||||||
|
val subtitleConfigs = subs.mapNotNull { subUrl ->
|
||||||
|
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
|
||||||
|
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
if (subtitleConfigs.isNotEmpty()) {
|
||||||
|
sideLoadedSubs = subtitleConfigs
|
||||||
|
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeForSubtitleUrl(url: String): String? {
|
||||||
|
val lower = url.substringBeforeLast('?').lowercase()
|
||||||
|
return when {
|
||||||
|
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
|
||||||
|
lower.endsWith(".srt") -> "application/x-subrip"
|
||||||
|
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
|
||||||
|
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stopProgressLoop()
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
playerView.player = null
|
||||||
|
tracksReadyFired = false
|
||||||
|
currentUrl = null
|
||||||
|
sideLoadedSubs = emptyList()
|
||||||
|
subtitleTrackList = emptyList()
|
||||||
|
audioTrackList = emptyList()
|
||||||
|
currentSubtitleId = 0
|
||||||
|
currentAudioId = 0
|
||||||
|
videoDecoderName = null
|
||||||
|
audioDecoderName = null
|
||||||
|
cumulativeDroppedFrames = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(positionSec: Double) {
|
||||||
|
player?.seekTo((positionSec * 1000).toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBy(offsetSec: Double) {
|
||||||
|
val p = player ?: return
|
||||||
|
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
|
||||||
|
p.seekTo(target.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeed(speed: Double) {
|
||||||
|
player?.playbackParameters = PlaybackParameters(speed.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpeed(): Float {
|
||||||
|
return player?.playbackParameters?.speed ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPaused(): Boolean {
|
||||||
|
return player?.isPlaying == false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentPosition(): Double {
|
||||||
|
return (player?.currentPosition ?: 0L) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDuration(): Double {
|
||||||
|
val d = player?.duration ?: 0L
|
||||||
|
return if (d > 0) d / 1000.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
|
||||||
|
|
||||||
|
data class TrackEntry(
|
||||||
|
val id: Int, // 1-based JS-facing ID
|
||||||
|
val trackGroupIndex: Int,
|
||||||
|
val trackIndex: Int,
|
||||||
|
val format: Format,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun rebuildTrackMaps(tracks: Tracks?) {
|
||||||
|
if (tracks == null) return
|
||||||
|
|
||||||
|
val subtitles = mutableListOf<TrackEntry>()
|
||||||
|
val audios = mutableListOf<TrackEntry>()
|
||||||
|
|
||||||
|
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||||
|
val rendererType = group.type
|
||||||
|
// Skip groups that have no tracks the player supports
|
||||||
|
for (trackIdx in 0 until group.length) {
|
||||||
|
if (!group.isTrackSupported(trackIdx)) continue
|
||||||
|
val format = group.getTrackFormat(trackIdx)
|
||||||
|
val entry = TrackEntry(
|
||||||
|
id = 0, // assigned per-list below
|
||||||
|
trackGroupIndex = groupIndex,
|
||||||
|
trackIndex = trackIdx,
|
||||||
|
format = format
|
||||||
|
)
|
||||||
|
when (rendererType) {
|
||||||
|
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
|
||||||
|
C.TRACK_TYPE_AUDIO -> audios.add(entry)
|
||||||
|
else -> { /* video / metadata ignored */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign 1-based IDs per track kind.
|
||||||
|
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
|
||||||
|
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
|
||||||
|
|
||||||
|
subtitleTrackList = subtitles
|
||||||
|
audioTrackList = audios
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyInitialTrackSelections() {
|
||||||
|
val p = player ?: return
|
||||||
|
val cfg = pendingConfig ?: return
|
||||||
|
|
||||||
|
// Initial subtitle/audio selection by 1-based ID.
|
||||||
|
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
|
||||||
|
setAudioTrack(cfg.initialAudioId)
|
||||||
|
}
|
||||||
|
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
|
||||||
|
disableSubtitles()
|
||||||
|
} else {
|
||||||
|
setSubtitleTrack(cfg.initialSubtitleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only apply once per source load.
|
||||||
|
pendingConfig = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
|
return subtitleTrackList.map { entry ->
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
// setOverrideForType replaces any existing override of the same
|
||||||
|
// track type — exactly what we want for single-track subtitle pickers.
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableSubtitles() {
|
||||||
|
val p = player ?: return
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentSubtitleId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
|
||||||
|
|
||||||
|
fun addSubtitleFile(url: String, select: Boolean) {
|
||||||
|
val p = player ?: return
|
||||||
|
val mime = mimeTypeForSubtitleUrl(url) ?: return
|
||||||
|
val currentMediaItem = p.currentMediaItem ?: return
|
||||||
|
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
|
||||||
|
.setMimeType(mime)
|
||||||
|
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Rebuild with the full accumulated list so previously loaded
|
||||||
|
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
|
||||||
|
// earlier addSubtitleFile calls) survive.
|
||||||
|
val combined = sideLoadedSubs + newSubConfig
|
||||||
|
sideLoadedSubs = combined
|
||||||
|
|
||||||
|
val rebuilt = currentMediaItem.buildUpon()
|
||||||
|
.setSubtitleConfigurations(combined)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val wasPlaying = p.isPlaying
|
||||||
|
val pos = p.currentPosition
|
||||||
|
p.setMediaItem(rebuilt, pos)
|
||||||
|
p.prepare()
|
||||||
|
if (wasPlaying) p.play()
|
||||||
|
|
||||||
|
// If text tracks were disabled (e.g. disableSubtitles was called
|
||||||
|
// earlier, or playback started with subtitles off), the new
|
||||||
|
// subtitle — even with SELECTION_FLAG_DEFAULT — won't render.
|
||||||
|
// Re-enable the text track type when the caller asks us to select.
|
||||||
|
if (select) {
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtitle Positioning / Styling
|
||||||
|
|
||||||
|
fun setSubtitlePosition(position: Int) {
|
||||||
|
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
|
||||||
|
// Map to SubtitleView's bottom-padding fraction. Reserve a small
|
||||||
|
// margin so 100 doesn't hug the very bottom edge.
|
||||||
|
val clamped = position.coerceIn(0, 100)
|
||||||
|
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleScale(scale: Double) {
|
||||||
|
subtitleScale = scale.toFloat()
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
|
// Margin in px (approximate). SubtitleView only accepts a single
|
||||||
|
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
|
||||||
|
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
|
||||||
|
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
|
||||||
|
subtitleBottomFraction = fraction
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
|
subtitleAlignY = alignment
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleFontSize(size: Int) {
|
||||||
|
subtitleFontSizePct = size
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBackgroundColor(colorHex: String) {
|
||||||
|
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
|
subtitleBorderStyle = style
|
||||||
|
applySubtitleStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(hex: String, fallback: Int): Int {
|
||||||
|
return try {
|
||||||
|
when {
|
||||||
|
hex.startsWith("#") && hex.length == 9 -> {
|
||||||
|
// #RRGGBBAA
|
||||||
|
val r = hex.substring(1, 3).toInt(16)
|
||||||
|
val g = hex.substring(3, 5).toInt(16)
|
||||||
|
val b = hex.substring(5, 7).toInt(16)
|
||||||
|
val a = hex.substring(7, 9).toInt(16)
|
||||||
|
Color.argb(a, r, g, b)
|
||||||
|
}
|
||||||
|
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySubtitleStyle() {
|
||||||
|
val sv = subtitleView ?: return
|
||||||
|
|
||||||
|
// Text size: explicit % wins; otherwise scale the default.
|
||||||
|
val textSizeFraction = if (subtitleFontSizePct != null) {
|
||||||
|
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
|
||||||
|
} else {
|
||||||
|
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
|
||||||
|
}
|
||||||
|
sv.setFractionalTextSize(textSizeFraction)
|
||||||
|
|
||||||
|
// Vertical position: explicit fraction (from setSubtitlePosition /
|
||||||
|
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
|
||||||
|
val alignYFraction = when (subtitleAlignY) {
|
||||||
|
"top" -> 0.9f
|
||||||
|
"center" -> 0.5f
|
||||||
|
else -> 0.08f // bottom
|
||||||
|
}
|
||||||
|
val bottomFraction = subtitleBottomFraction ?: alignYFraction
|
||||||
|
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
|
||||||
|
|
||||||
|
// Edge / background style.
|
||||||
|
val foreground = Color.WHITE
|
||||||
|
val edgeType: Int
|
||||||
|
val backgroundColor: Int
|
||||||
|
when (subtitleBorderStyle) {
|
||||||
|
"background-box" -> {
|
||||||
|
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
|
||||||
|
// subtitleBackgroundColor already carries its own alpha
|
||||||
|
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
|
||||||
|
// Alpha 0 → transparent, matching user intent.
|
||||||
|
backgroundColor = subtitleBackgroundColor
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// "outline-and-shadow"
|
||||||
|
edgeType = if (subtitleAlignY == "center")
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||||
|
else
|
||||||
|
CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||||
|
backgroundColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val style = CaptionStyleCompat(
|
||||||
|
foreground,
|
||||||
|
backgroundColor,
|
||||||
|
Color.TRANSPARENT,
|
||||||
|
edgeType,
|
||||||
|
Color.BLACK,
|
||||||
|
Typeface.SANS_SERIF
|
||||||
|
)
|
||||||
|
sv.setApplyEmbeddedStyles(false)
|
||||||
|
sv.setApplyEmbeddedFontSizes(false)
|
||||||
|
sv.setStyle(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
|
return audioTrackList.map { entry ->
|
||||||
|
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
|
||||||
|
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
|
||||||
|
else entry.format.channelCount
|
||||||
|
mapOf(
|
||||||
|
"id" to entry.id,
|
||||||
|
"title" to (entry.format.label ?: ""),
|
||||||
|
"lang" to (entry.format.language ?: ""),
|
||||||
|
"codec" to (entry.format.sampleMimeType ?: ""),
|
||||||
|
"channels" to channels
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAudioTrack(trackId: Int) {
|
||||||
|
val p = player ?: return
|
||||||
|
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
|
||||||
|
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||||
|
|
||||||
|
val params = p.trackSelectionParameters.buildUpon()
|
||||||
|
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||||
|
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||||
|
.build()
|
||||||
|
p.trackSelectionParameters = params
|
||||||
|
currentAudioId = trackId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAudioTrack(): Int = currentAudioId
|
||||||
|
|
||||||
|
// MARK: - Video Scaling
|
||||||
|
|
||||||
|
fun setZoomedToFill(zoomed: Boolean) {
|
||||||
|
isZoomedToFill = zoomed
|
||||||
|
val resizeMode = if (zoomed) {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
|
} else {
|
||||||
|
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
}
|
||||||
|
playerView.resizeMode = resizeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isZoomedToFill(): Boolean = isZoomedToFill
|
||||||
|
|
||||||
|
// MARK: - Technical Info
|
||||||
|
|
||||||
|
fun getTechnicalInfo(): Map<String, Any> {
|
||||||
|
val p = player ?: return emptyMap()
|
||||||
|
val tracks = p.currentTracks
|
||||||
|
|
||||||
|
// Prefer the currently-selected track within each renderer group;
|
||||||
|
// fall back to the first supported track if none is selected yet.
|
||||||
|
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
|
||||||
|
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
|
||||||
|
|
||||||
|
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
|
||||||
|
(p.bufferedPosition - p.currentPosition) / 1000.0
|
||||||
|
} else 0.0
|
||||||
|
|
||||||
|
val info = LinkedHashMap<String, Any>()
|
||||||
|
info["cacheSeconds"] = cacheSec
|
||||||
|
|
||||||
|
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
|
||||||
|
if (cumulativeDroppedFrames > 0) {
|
||||||
|
info["droppedFrames"] = cumulativeDroppedFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
|
||||||
|
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
|
||||||
|
// FFmpeg extension reports names beginning with "FFmpeg", which we
|
||||||
|
// classify as software; everything else is MediaCodec (hardware).
|
||||||
|
videoDecoderName?.let { name ->
|
||||||
|
info["decoderName"] = name
|
||||||
|
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
|
||||||
|
"software"
|
||||||
|
} else {
|
||||||
|
"hardware"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoFormat?.let { f ->
|
||||||
|
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
|
||||||
|
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
|
||||||
|
f.sampleMimeType?.let { info["videoCodec"] = it }
|
||||||
|
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
|
||||||
|
// overlay skips the row instead of showing "-1".
|
||||||
|
if (f.frameRate > 0f) {
|
||||||
|
info["fps"] = f.frameRate.toDouble()
|
||||||
|
}
|
||||||
|
// Bitrate: prefer average, fall back to peak. Both can be
|
||||||
|
// NO_VALUE for adaptive HLS renditions — omit when unknown
|
||||||
|
// rather than reporting 0 Kbps.
|
||||||
|
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
|
||||||
|
info["videoBitrate"] = vBitrate.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
|
||||||
|
// Carries profile / tier / level / constraint bytes — power
|
||||||
|
// users can decode it manually to see why a stream hit our
|
||||||
|
// HEVC level cap.
|
||||||
|
f.codecs?.let { info["videoCodecs"] = it }
|
||||||
|
|
||||||
|
// HDR / color metadata. Format.colorInfo is the authoritative
|
||||||
|
// source — the file/Jellyfin may claim HDR but the player is
|
||||||
|
// what decides whether the decoder+surface path is HDR-capable.
|
||||||
|
f.colorInfo?.let { ci ->
|
||||||
|
val hdr = deriveHdrFormat(ci)
|
||||||
|
if (hdr != null) info["hdrFormat"] = hdr
|
||||||
|
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
|
||||||
|
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
|
||||||
|
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioFormat?.let { f ->
|
||||||
|
f.sampleMimeType?.let { info["audioCodec"] = it }
|
||||||
|
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||||
|
f.averageBitrate
|
||||||
|
} else {
|
||||||
|
f.peakBitrate
|
||||||
|
}
|
||||||
|
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
|
||||||
|
info["audioBitrate"] = aBitrate.toDouble()
|
||||||
|
}
|
||||||
|
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
|
||||||
|
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the active color transfer to a human-readable HDR format string.
|
||||||
|
* Returns null for SDR / unknown so the overlay can skip the row.
|
||||||
|
*
|
||||||
|
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
|
||||||
|
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
|
||||||
|
* exposed on Format. Both report as "HDR10" here; that matches what
|
||||||
|
* Media3 actually decodes (no HDR10+ tone-mapping).
|
||||||
|
*/
|
||||||
|
private fun deriveHdrFormat(ci: ColorInfo): String? {
|
||||||
|
return when (ci.colorTransfer) {
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "HDR10"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorSpaceName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_SPACE_BT709 -> "BT.709"
|
||||||
|
C.COLOR_SPACE_BT601 -> "BT.601"
|
||||||
|
C.COLOR_SPACE_BT2020 -> "BT.2020"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorRangeName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_RANGE_LIMITED -> "Limited"
|
||||||
|
C.COLOR_RANGE_FULL -> "Full"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorTransferName(value: Int): String? = when (value) {
|
||||||
|
Format.NO_VALUE -> null
|
||||||
|
C.COLOR_TRANSFER_SDR -> "SDR"
|
||||||
|
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
|
||||||
|
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||||
|
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
|
||||||
|
else -> "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickFormat(tracks: Tracks, type: Int): Format? {
|
||||||
|
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
|
||||||
|
// Selected track wins.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
// Otherwise the first supported track.
|
||||||
|
for (i in 0 until group.length) {
|
||||||
|
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Loop
|
||||||
|
|
||||||
|
private fun startProgressLoop() {
|
||||||
|
stopProgressLoop()
|
||||||
|
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopProgressLoop() {
|
||||||
|
mainHandler.removeCallbacks(progressRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
6
modules/exoplayer-player/expo-module.config.json
Normal file
6
modules/exoplayer-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
modules/exoplayer-player/index.ts
Normal file
19
modules/exoplayer-player/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Re-export the shared player contract from mpv-player so ExoPlayer
|
||||||
|
// and MPV present identical surfaces to React. The MPV-prefixed setting
|
||||||
|
// keys keep their names to avoid migrating existing installs.
|
||||||
|
export type {
|
||||||
|
AudioTrack,
|
||||||
|
MpvPlayerViewProps,
|
||||||
|
MpvPlayerViewRef,
|
||||||
|
NowPlayingMetadata,
|
||||||
|
OnErrorEventPayload,
|
||||||
|
OnLoadEventPayload,
|
||||||
|
OnPictureInPictureChangePayload,
|
||||||
|
OnPlaybackStateChangePayload,
|
||||||
|
OnProgressEventPayload,
|
||||||
|
OnTracksReadyEventPayload,
|
||||||
|
SubtitleTrack,
|
||||||
|
TechnicalInfo,
|
||||||
|
VideoSource,
|
||||||
|
} from "../mpv-player/src/MpvPlayer.types";
|
||||||
|
export { default as ExoPlayerView } from "./src/ExoPlayerView";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user