mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-01 07:58:08 +00:00
Compare commits
31 Commits
refactor/n
...
feat/hide-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b127df39a7 | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 | ||
|
|
94362169b6 | ||
|
|
8aefdac50f | ||
|
|
665a79924a | ||
|
|
b9ddcf8404 | ||
|
|
64ffc8db8b | ||
|
|
2a61124a0d | ||
|
|
36178c2082 | ||
|
|
e1c69a9ec9 | ||
|
|
01110b8d13 | ||
|
|
21034f5671 | ||
|
|
1439bcee0d | ||
|
|
9a906e6d39 | ||
|
|
48de7b7c6d | ||
|
|
85e5c25206 | ||
|
|
3dc84818e8 | ||
|
|
18102a3045 |
14
.github/workflows/build-apps.yml
vendored
14
.github/workflows/build-apps.yml
vendored
@@ -156,7 +156,7 @@ jobs:
|
|||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-15
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -191,6 +191,11 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
@@ -219,7 +224,7 @@ jobs:
|
|||||||
# Disabled for now - uncomment when ready to build iOS TV
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# build-ios-tv:
|
# build-ios-tv:
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
# runs-on: macos-15
|
# runs-on: macos-26
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: read
|
# contents: read
|
||||||
@@ -254,6 +259,11 @@ jobs:
|
|||||||
# - name: 🛠️ Generate project files
|
# - name: 🛠️ Generate project files
|
||||||
# run: bun run prebuild:tv
|
# run: bun run prebuild:tv
|
||||||
#
|
#
|
||||||
|
# - name: 🔧 Setup Xcode
|
||||||
|
# uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
# with:
|
||||||
|
# xcode-version: '26.0.1'
|
||||||
|
#
|
||||||
# - name: 🏗️ Setup EAS
|
# - name: 🏗️ Setup EAS
|
||||||
# uses: expo/expo-github-action@main
|
# uses: expo/expo-github-action@main
|
||||||
# with:
|
# with:
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
|
|||||||
177
.vscode/settings.json
vendored
177
.vscode/settings.json
vendored
@@ -1,178 +1,25 @@
|
|||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// FORMATTING & LINTING
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Biome as default formatter
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.codeActionsOnSave": {
|
||||||
"editor.formatOnType": false,
|
"source.fixAll.biome": "explicit"
|
||||||
|
|
||||||
// Language-specific formatters
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSaveMode": "file"
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TYPESCRIPT & JAVASCRIPT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// TypeScript performance optimizations
|
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
|
||||||
"typescript.suggest.autoImports": true,
|
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
|
||||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
|
||||||
|
|
||||||
// JavaScript settings
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"javascript.suggest.autoImports": true,
|
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REACT NATIVE & EXPO
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// File associations for React Native
|
|
||||||
"files.associations": {
|
|
||||||
"*.expo.ts": "typescript",
|
|
||||||
"*.expo.tsx": "typescriptreact",
|
|
||||||
"*.expo.js": "javascript",
|
|
||||||
"*.expo.jsx": "javascriptreact",
|
|
||||||
"metro.config.js": "javascript",
|
|
||||||
"babel.config.js": "javascript",
|
|
||||||
"app.config.js": "javascript",
|
|
||||||
"eas.json": "jsonc"
|
|
||||||
},
|
|
||||||
|
|
||||||
// React Native specific settings
|
|
||||||
"emmet.includeLanguages": {
|
|
||||||
"typescriptreact": "html",
|
|
||||||
"javascriptreact": "html"
|
|
||||||
},
|
|
||||||
"emmet.triggerExpansionOnTab": true,
|
|
||||||
|
|
||||||
// Exclude build directories from search
|
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EDITOR PERFORMANCE & UX
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
"editor.largeFileOptimizations": true,
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
|
||||||
"**/.git/subtree-cache/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/.expo/**": true,
|
|
||||||
"**/ios/**": true,
|
|
||||||
"**/android/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better editor behavior
|
|
||||||
"editor.suggestSelection": "first",
|
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"strings": true,
|
|
||||||
"comments": true,
|
|
||||||
"other": true
|
|
||||||
},
|
|
||||||
"editor.snippetSuggestions": "top",
|
|
||||||
"editor.tabCompletion": "on",
|
|
||||||
"editor.wordBasedSuggestions": "off",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TERMINAL & DEVELOPMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Terminal settings for Bun (Windows-specific)
|
|
||||||
"terminal.integrated.profiles.windows": {
|
|
||||||
"Command Prompt": {
|
|
||||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
|
||||||
"env": {
|
|
||||||
"PATH": "${env:PATH};./node_modules/.bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORKSPACE & NAVIGATION
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better workspace navigation
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.expand": false,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"*.ts": "${capture}.js",
|
|
||||||
"*.tsx": "${capture}.js",
|
|
||||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
|
||||||
"*.jsx": "${capture}.js",
|
|
||||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
|
||||||
"tsconfig.json": "tsconfig.*.json",
|
|
||||||
".env": ".env.*",
|
|
||||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
|
||||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better breadcrumbs and navigation
|
|
||||||
"breadcrumbs.enabled": true,
|
|
||||||
"outline.showVariables": true,
|
|
||||||
"outline.showConstants": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// GIT & VERSION CONTROL
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Git integration
|
|
||||||
"git.autofetch": true,
|
|
||||||
"git.enableSmartCommit": true,
|
|
||||||
"git.confirmSync": false,
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CODE QUALITY & ERRORS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better error detection
|
|
||||||
"typescript.validate.enable": true,
|
|
||||||
"javascript.validate.enable": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Problem matcher for better error display
|
|
||||||
"typescript.tsc.autoDetect": "on"
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
app.json
10
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.1",
|
"version": "0.48.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -29,16 +29,12 @@
|
|||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": {
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"dark": "./assets/images/icon-ios-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
},
|
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 84,
|
"versionCode": 85,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing 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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { 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 { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -11,6 +11,11 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
// Conditionally import expo-sharing only on non-TV platforms
|
||||||
|
const Sharing = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("expo-sharing") as typeof SharingType);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
@@ -49,6 +54,8 @@ export default function Page() {
|
|||||||
|
|
||||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
const share = useCallback(async () => {
|
const share = useCallback(async () => {
|
||||||
|
if (!Sharing) return;
|
||||||
|
|
||||||
const logsFile = new File(Paths.document, "logs.txt");
|
const logsFile = new File(Paths.document, "logs.txt");
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -60,9 +67,11 @@ export default function Page() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filteredLogs]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
loading ? (
|
loading ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ControlsSettings } from "@/components/settings/ControlsSettings";
|
||||||
import { GestureControls } from "@/components/settings/GestureControls";
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
@@ -25,6 +26,7 @@ export default function PlaybackControlsPage() {
|
|||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
<ControlsSettings className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<PlaybackControlsSettings />
|
||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -20,7 +21,14 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
const { data: item, isError } = useItemQuery(id, false, undefined, [
|
||||||
|
ItemFields.MediaSources,
|
||||||
|
ItemFields.MediaSourceCount,
|
||||||
|
ItemFields.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// preload media sources in background
|
||||||
|
useItemQuery(id, false, undefined, []);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useCallback, useEffect, 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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
@@ -33,8 +34,16 @@ import {
|
|||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
@@ -58,7 +67,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -91,6 +100,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -131,9 +180,11 @@ const Page: React.FC = () => {
|
|||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: mediaType!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
...(mediaType === MediaType.TV && {
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
@@ -332,6 +383,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||||
@@ -673,7 +674,30 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
// Prepare metadata for iOS native media controls
|
||||||
|
const nowPlayingMetadata = useMemo(() => {
|
||||||
|
if (!item || !api) return undefined;
|
||||||
|
|
||||||
|
const artworkUri = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.Name || "",
|
||||||
|
artist:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? item.SeriesName || ""
|
||||||
|
: item.AlbumArtist || "",
|
||||||
|
albumTitle:
|
||||||
|
item.Type === "Episode" && item.SeasonName
|
||||||
|
? item.SeasonName
|
||||||
|
: undefined,
|
||||||
|
artworkUri: artworkUri || undefined,
|
||||||
|
};
|
||||||
|
}, [item, api]);
|
||||||
|
|
||||||
// Show error UI first, before checking loading/missing‐data
|
// Show error UI first, before checking loading/missing‐data
|
||||||
if (itemStatus.isError || streamStatus.isError) {
|
if (itemStatus.isError || streamStatus.isError) {
|
||||||
@@ -731,6 +755,7 @@ export default function page() {
|
|||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}>({
|
}>({
|
||||||
username: _username,
|
username: _username || "",
|
||||||
password: _password,
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,6 +264,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -272,6 +278,8 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
@@ -280,6 +288,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -289,10 +303,17 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className='mt-4'>
|
<View className='mt-4'>
|
||||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View className='mt-3'>
|
<View className='mt-3'>
|
||||||
<Button
|
<Button
|
||||||
@@ -334,6 +355,8 @@ const Login: React.FC = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Full-width primary button */}
|
{/* Full-width primary button */}
|
||||||
@@ -394,6 +417,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -410,6 +439,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -423,6 +458,7 @@ const Login: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
className='flex-1 mr-2'
|
className='flex-1 mr-2'
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g>
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blur-material": 0.3,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer1.svg",
|
||||||
|
"name": "streamyfin_logo_layer1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 1
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.8,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.75000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer2.svg",
|
||||||
|
"name": "streamyfin_logo_layer2",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1,
|
||||||
|
"translation-in-points": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, -0.01613253252572302]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "layer-color",
|
||||||
|
"opacity": 0.35
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency-specializations": [
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.29000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer3.svg",
|
||||||
|
"name": "streamyfin_logo_layer3",
|
||||||
|
"opacity": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.8,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"glass": true,
|
||||||
|
"hidden-specializations": [
|
||||||
|
{
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image-name": "streamyfin_logo_layer4.svg",
|
||||||
|
"name": "streamyfin_logo_layer4",
|
||||||
|
"opacity-specializations": [
|
||||||
|
{
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "combined",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.9,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": false,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"circles": ["watchOS"],
|
||||||
|
"squares": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
bun.lock
35
bun.lock
@@ -50,14 +50,14 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^15.0.0",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-google-cast": "^4.9.1",
|
"react-native-google-cast": "^4.9.1",
|
||||||
@@ -85,21 +85,20 @@
|
|||||||
"zod": "^4.1.3",
|
"zod": "^4.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.0",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.0",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.1.5",
|
"lint-staged": "16.2.6",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1560,8 +1559,6 @@
|
|||||||
|
|
||||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
|
|
||||||
|
|
||||||
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
||||||
@@ -1608,7 +1605,7 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
|
"react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
"react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="],
|
||||||
|
|
||||||
@@ -1624,7 +1621,7 @@
|
|||||||
|
|
||||||
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
|
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
|
||||||
|
|
||||||
"react-native-device-info": ["react-native-device-info@14.1.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw=="],
|
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
|
||||||
|
|
||||||
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
|
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +17,58 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
const getColorClasses = (
|
||||||
|
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||||
|
variant: "solid" | "border",
|
||||||
|
focused: boolean,
|
||||||
|
): string => {
|
||||||
|
if (variant === "border") {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-purple-400"
|
||||||
|
: "bg-transparent border-2 border-purple-600";
|
||||||
|
case "red":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-red-400"
|
||||||
|
: "bg-transparent border-2 border-red-600";
|
||||||
|
case "black":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-neutral-700"
|
||||||
|
: "bg-transparent border-2 border-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-100"
|
||||||
|
: "bg-transparent border-2 border-white";
|
||||||
|
case "transparent":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-400"
|
||||||
|
: "bg-transparent border-2 border-gray-600";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-gray-100 border-2 border-gray-300"
|
||||||
|
: "bg-white border border-gray-200";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent";
|
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||||
|
variant?: "solid" | "border";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const colorClasses = getColorClasses(color, variant, focused);
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
}
|
|
||||||
}, [color, focused]);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const textColorClass =
|
||||||
|
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
${textColorClass} font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -132,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
return itemsNotDownloaded.length === 0;
|
return itemsNotDownloaded.length === 0;
|
||||||
}, [items, itemsNotDownloaded]);
|
}, [items, itemsNotDownloaded]);
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() =>
|
||||||
|
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
|
||||||
|
[],
|
||||||
[processes, itemIds],
|
[processes, itemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (itemIds.length === 1)
|
if (itemIds.length === 1)
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
|
||||||
return (
|
return (
|
||||||
((itemIds.length -
|
((itemIds.length -
|
||||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||||
@@ -262,9 +264,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
// Wait for modal dismiss animation to complete
|
// Wait for modal dismiss animation to complete
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
initiateDownload(...itemsToDownload);
|
initiateDownload(...itemsToDownload);
|
||||||
});
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
@@ -29,13 +29,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -59,7 +56,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -201,76 +197,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
<BitrateSheet
|
<PlayButton
|
||||||
className='mr-1'
|
selectedOptions={selectedOptions}
|
||||||
onChange={(val) =>
|
item={item}
|
||||||
setSelectedOptions(
|
isOffline={isOffline}
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
colors={itemColors}
|
||||||
)
|
/>
|
||||||
}
|
<View className='w-1' />
|
||||||
selected={selectedOptions.bitrate}
|
{!isOffline && (
|
||||||
/>
|
<MediaSourceButton
|
||||||
<MediaSourceSheet
|
selectedOptions={selectedOptions}
|
||||||
className='mr-1'
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(val) =>
|
colors={itemColors}
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
/>
|
||||||
<TrackSheet
|
)}
|
||||||
className='mr-1'
|
</View>
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
|
||||||
className='grow'
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,9 +226,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
{!isOffline &&
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
)}
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
|
|||||||
203
components/MediaSourceButton.tsx
Normal file
203
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data: itemWithSources, isLoading } = useItemQuery(
|
||||||
|
item.Id,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = itemWithSources?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [itemWithSources, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (
|
||||||
|
itemWithSources?.MediaSources &&
|
||||||
|
itemWithSources.MediaSources.length > 1
|
||||||
|
) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: itemWithSources.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
itemWithSources,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item || isLoading}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [controlledOpen]);
|
}, [controlledOpen]);
|
||||||
|
|
||||||
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||||
|
// and sync the controlled open state
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -24,6 +25,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -33,6 +36,8 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
@@ -55,6 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -84,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -271,6 +274,118 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Check if item is downloaded
|
||||||
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
if (downloadedItem) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
// Show bottom sheet for Android
|
||||||
|
showModal(
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='px-4 mt-4 mb-12'>
|
||||||
|
<View className='pb-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-2'>
|
||||||
|
{t("player.downloaded_file_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-70 text-base'>
|
||||||
|
{t("player.downloaded_file_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Play downloaded file"
|
||||||
|
: t("player.downloaded_file_yes")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}}
|
||||||
|
color='white'
|
||||||
|
variant='border'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Stream file"
|
||||||
|
: t("player.downloaded_file_no")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>,
|
||||||
|
{
|
||||||
|
snapPoints: ["35%"],
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show alert for iOS
|
||||||
|
Alert.alert(
|
||||||
|
t("player.downloaded_file_title"),
|
||||||
|
t("player.downloaded_file_message"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_yes"),
|
||||||
|
onPress: () => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
},
|
||||||
|
isPreferred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_no"),
|
||||||
|
onPress: () => {
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not downloaded, proceed with normal flow
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
lightHapticFeedback,
|
||||||
|
handleNormalPlayFlow,
|
||||||
|
goToPlayer,
|
||||||
|
t,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
effectiveColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -358,9 +473,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
// if (Platform.OS === "ios")
|
||||||
// return (
|
// return (
|
||||||
@@ -414,7 +526,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function Input(props: InputProps) {
|
|||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => inputRef?.current?.focus?.()}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ interface ActiveDownloadsProps extends ViewProps {}
|
|||||||
|
|
||||||
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||||
const { processes } = useDownload();
|
const { processes } = useDownload();
|
||||||
if (processes?.length === 0)
|
|
||||||
|
// Filter out any invalid processes before rendering
|
||||||
|
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
|
||||||
|
|
||||||
|
if (validProcesses.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
@@ -27,8 +31,8 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
|||||||
{t("home.downloads.active_downloads")}
|
{t("home.downloads.active_downloads")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='gap-y-2'>
|
<View className='gap-y-2'>
|
||||||
{processes?.map((p: JobStatus) => (
|
{validProcesses.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
const eta = useMemo(() => {
|
||||||
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +66,14 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
return formatTimeString(secondsRemaining, "s");
|
||||||
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
|
||||||
|
|
||||||
const estimatedSize = useMemo(() => {
|
const estimatedSize = useMemo(() => {
|
||||||
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
|
if (process?.estimatedTotalSizeBytes)
|
||||||
|
return process.estimatedTotalSizeBytes;
|
||||||
|
|
||||||
// Calculate from bitrate + duration (only if bitrate value is defined)
|
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||||
if (process.maxBitrate.value) {
|
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
|
||||||
return estimateDownloadSize(
|
return estimateDownloadSize(
|
||||||
process.maxBitrate.value,
|
process.maxBitrate.value,
|
||||||
process.item.RunTimeTicks,
|
process.item.RunTimeTicks,
|
||||||
@@ -81,32 +82,43 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [
|
}, [
|
||||||
process.maxBitrate.value,
|
process?.maxBitrate?.value,
|
||||||
process.item.RunTimeTicks,
|
process?.item?.RunTimeTicks,
|
||||||
process.estimatedTotalSizeBytes,
|
process?.estimatedTotalSizeBytes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isTranscoding = process.isTranscoding || false;
|
const isTranscoding = process?.isTranscoding || false;
|
||||||
|
|
||||||
const downloadedAmount = useMemo(() => {
|
const downloadedAmount = useMemo(() => {
|
||||||
if (!process.bytesDownloaded) return null;
|
if (!process?.bytesDownloaded) return null;
|
||||||
return formatBytes(process.bytesDownloaded);
|
return formatBytes(process.bytesDownloaded);
|
||||||
}, [process.bytesDownloaded]);
|
}, [process?.bytesDownloaded]);
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.Id!);
|
try {
|
||||||
}, []);
|
const itemId = process?.item?.Id;
|
||||||
|
if (!itemId) return undefined;
|
||||||
|
return storage.getString(itemId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [process?.item?.Id]);
|
||||||
|
|
||||||
// Sanitize progress to ensure it's within valid bounds
|
// Sanitize progress to ensure it's within valid bounds
|
||||||
const sanitizedProgress = useMemo(() => {
|
const sanitizedProgress = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
typeof process.progress !== "number" ||
|
typeof process?.progress !== "number" ||
|
||||||
Number.isNaN(process.progress)
|
Number.isNaN(process.progress)
|
||||||
) {
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return Math.max(0, Math.min(100, process.progress));
|
return Math.max(0, Math.min(100, process.progress));
|
||||||
}, [process.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
|
// Return null after all hooks have been called
|
||||||
|
if (!process || !process.item || !process.item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ export const Home = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("scrollY", scrollY);
|
|
||||||
}, [scrollY]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
invalidateCache();
|
invalidateCache();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -64,6 +65,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// Flatten all pages into a single array
|
// Flatten all pages into a single array
|
||||||
const allItems = data?.pages.flat() || [];
|
const allItems = data?.pages.flat() || [];
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||||
|
return allItems.map((_, index) => index * itemWidth);
|
||||||
|
}, [allItems, orientation]);
|
||||||
|
|
||||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
@@ -126,6 +132,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
>
|
>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className='px-4 flex flex-row'>
|
||||||
{allItems.map((item) => (
|
{allItems.map((item) => (
|
||||||
|
|||||||
@@ -144,11 +144,14 @@ const RequestModal = forwardRef<
|
|||||||
}, [defaultServiceDetails]);
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
const seasonTitle = useMemo(() => {
|
const seasonTitle = useMemo(() => {
|
||||||
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
|
if (!requestBody?.seasons || requestBody.seasons.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (requestBody.seasons.length > 1) {
|
||||||
return t("jellyseerr.season_all");
|
return t("jellyseerr.season_all");
|
||||||
}
|
}
|
||||||
return t("jellyseerr.season_number", {
|
return t("jellyseerr.season_number", {
|
||||||
season_number: requestBody?.seasons,
|
season_number: requestBody.seasons[0],
|
||||||
});
|
});
|
||||||
}, [requestBody?.seasons]);
|
}, [requestBody?.seasons]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
[api, user?.Id, collection?.Id],
|
[api, user?.Id, collection?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
// Generate offsets for a reasonable number of items
|
||||||
|
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["media-list", collection.Id!]}
|
queryKey={["media-list", collection.Id!]}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|
||||||
const scrollToIndex = (index: number) => {
|
const scrollToIndex = (index: number) => {
|
||||||
scrollRef.current?.scrollToIndex(index, 16);
|
scrollRef.current?.scrollToIndex(index, -16);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seasonId = useMemo(() => {
|
const seasonId = useMemo(() => {
|
||||||
@@ -87,6 +87,11 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [episodes, item]);
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
|
||||||
|
return episodes?.map((_, index) => index * itemWidth) || [];
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -109,6 +114,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
<ItemCardText item={_item} />
|
<ItemCardText item={_item} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
76
components/settings/ControlsSettings.tsx
Normal file
76
components/settings/ControlsSettings.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import DisabledSetting from "./DisabledSetting";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const ControlsSettings: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const disabled = useMemo(
|
||||||
|
() =>
|
||||||
|
pluginSettings?.showVolumeSlider?.locked === true &&
|
||||||
|
pluginSettings?.showBrightnessSlider?.locked === true &&
|
||||||
|
pluginSettings?.showSeekButtons?.locked === true,
|
||||||
|
[pluginSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting disabled={disabled} {...props}>
|
||||||
|
<ListGroup title={t("home.settings.controls.controls_title")}>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.controls.show_volume_slider")}
|
||||||
|
subtitle={t("home.settings.controls.show_volume_slider_description")}
|
||||||
|
disabled={pluginSettings?.showVolumeSlider?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showVolumeSlider}
|
||||||
|
disabled={pluginSettings?.showVolumeSlider?.locked}
|
||||||
|
onValueChange={(showVolumeSlider) =>
|
||||||
|
updateSettings({ showVolumeSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.controls.show_brightness_slider")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.controls.show_brightness_slider_description",
|
||||||
|
)}
|
||||||
|
disabled={pluginSettings?.showBrightnessSlider?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showBrightnessSlider}
|
||||||
|
disabled={pluginSettings?.showBrightnessSlider?.locked}
|
||||||
|
onValueChange={(showBrightnessSlider) =>
|
||||||
|
updateSettings({ showBrightnessSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.controls.show_seek_buttons")}
|
||||||
|
subtitle={t("home.settings.controls.show_seek_buttons_description")}
|
||||||
|
disabled={pluginSettings?.showSeekButtons?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showSeekButtons}
|
||||||
|
disabled={pluginSettings?.showSeekButtons?.locked}
|
||||||
|
onValueChange={(showSeekButtons) =>
|
||||||
|
updateSettings({ showSeekButtons })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -48,49 +48,57 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
}}
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
<View
|
{settings?.showBrightnessSlider && (
|
||||||
style={{
|
<View
|
||||||
position: "absolute",
|
style={{
|
||||||
alignItems: "center",
|
position: "absolute",
|
||||||
transform: [{ rotate: "270deg" }],
|
alignItems: "center",
|
||||||
left: 0,
|
transform: [{ rotate: "270deg" }],
|
||||||
bottom: 30,
|
left: 0,
|
||||||
}}
|
bottom: 30,
|
||||||
>
|
}}
|
||||||
<BrightnessSlider />
|
>
|
||||||
</View>
|
<BrightnessSlider />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV ? (
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
settings?.showSeekButtons ? (
|
||||||
<View
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
style={{
|
<View
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={ICON_SIZES.CENTER}
|
|
||||||
color='white'
|
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
position: "relative",
|
||||||
}}
|
justifyContent: "center",
|
||||||
/>
|
alignItems: "center",
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{settings?.rewindSkipTime}
|
<Ionicons
|
||||||
</Text>
|
name='refresh-outline'
|
||||||
</View>
|
size={ICON_SIZES.CENTER}
|
||||||
</TouchableOpacity>
|
color='white'
|
||||||
)}
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.rewindSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
@@ -106,47 +114,55 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV ? (
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
settings?.showSeekButtons ? (
|
||||||
<View
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
style={{
|
<View
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={ICON_SIZES.CENTER}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "relative",
|
||||||
color: "white",
|
justifyContent: "center",
|
||||||
fontSize: 16,
|
alignItems: "center",
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{settings?.forwardSkipTime}
|
<Ionicons
|
||||||
</Text>
|
name='refresh-outline'
|
||||||
</View>
|
size={ICON_SIZES.CENTER}
|
||||||
</TouchableOpacity>
|
color='white'
|
||||||
)}
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
color: "white",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "bold",
|
||||||
|
bottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settings?.forwardSkipTime}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View
|
{settings?.showVolumeSlider && (
|
||||||
style={{
|
<View
|
||||||
position: "absolute",
|
style={{
|
||||||
alignItems: "center",
|
position: "absolute",
|
||||||
transform: [{ rotate: "270deg" }],
|
alignItems: "center",
|
||||||
bottom: 30,
|
transform: [{ rotate: "270deg" }],
|
||||||
right: 0,
|
bottom: 30,
|
||||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
right: 0,
|
||||||
}}
|
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||||
>
|
}}
|
||||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
>
|
||||||
</View>
|
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
|||||||
buttonText,
|
buttonText,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
console.log(`[SKIP_BUTTON] Render:`, {
|
|
||||||
buttonText,
|
|
||||||
showButton,
|
|
||||||
className: showButton ? "flex" : "hidden",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (getSubtitleTracks) {
|
if (getSubtitleTracks) {
|
||||||
let subtitleData = await getSubtitleTracks();
|
let subtitleData: TrackInfo[] | null = null;
|
||||||
|
try {
|
||||||
|
subtitleData = await getSubtitleTracks();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoContext] Failed to get subtitle tracks:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||||
if (
|
if (
|
||||||
mediaSource?.TranscodingUrl &&
|
mediaSource?.TranscodingUrl &&
|
||||||
@@ -179,7 +185,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
if (getAudioTracks) {
|
if (getAudioTracks) {
|
||||||
const audioData = await getAudioTracks();
|
let audioData: TrackInfo[] | null = null;
|
||||||
|
try {
|
||||||
|
audioData = await getAudioTracks();
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoContext] Failed to get audio tracks:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const allAudio =
|
const allAudio =
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const useGestureDetection = ({
|
|||||||
onVerticalDragEnd,
|
onVerticalDragEnd,
|
||||||
onTap,
|
onTap,
|
||||||
screenWidth = 400,
|
screenWidth = 400,
|
||||||
|
screenHeight = 800,
|
||||||
}: SwipeGestureOptions = {}) => {
|
}: SwipeGestureOptions = {}) => {
|
||||||
const touchStartTime = useRef(0);
|
const touchStartTime = useRef(0);
|
||||||
const touchStartPosition = useRef({ x: 0, y: 0 });
|
const touchStartPosition = useRef({ x: 0, y: 0 });
|
||||||
@@ -36,25 +37,47 @@ export const useGestureDetection = ({
|
|||||||
const dragSide = useRef<"left" | "right" | null>(null);
|
const dragSide = useRef<"left" | "right" | null>(null);
|
||||||
const hasMovedEnough = useRef(false);
|
const hasMovedEnough = useRef(false);
|
||||||
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
||||||
|
const shouldIgnoreTouch = useRef(false);
|
||||||
|
|
||||||
const handleTouchStart = useCallback((event: GestureResponderEvent) => {
|
const handleTouchStart = useCallback(
|
||||||
touchStartTime.current = Date.now();
|
(event: GestureResponderEvent) => {
|
||||||
touchStartPosition.current = {
|
const startY = event.nativeEvent.pageY;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Define exclusion zones (15% from top and bottom)
|
||||||
};
|
const topExclusionZone = screenHeight * 0.15;
|
||||||
lastTouchPosition.current = {
|
const bottomExclusionZone = screenHeight * 0.85;
|
||||||
x: event.nativeEvent.pageX,
|
|
||||||
y: event.nativeEvent.pageY,
|
// Check if touch started in exclusion zones
|
||||||
};
|
if (startY < topExclusionZone || startY > bottomExclusionZone) {
|
||||||
isDragging.current = false;
|
shouldIgnoreTouch.current = true;
|
||||||
dragSide.current = null;
|
return;
|
||||||
hasMovedEnough.current = false;
|
}
|
||||||
gestureType.current = "none";
|
|
||||||
}, []);
|
shouldIgnoreTouch.current = false;
|
||||||
|
touchStartTime.current = Date.now();
|
||||||
|
touchStartPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
lastTouchPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: startY,
|
||||||
|
};
|
||||||
|
isDragging.current = false;
|
||||||
|
dragSide.current = null;
|
||||||
|
hasMovedEnough.current = false;
|
||||||
|
gestureType.current = "none";
|
||||||
|
},
|
||||||
|
[screenHeight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchMove = useCallback(
|
const handleTouchMove = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentPosition = {
|
const currentPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
y: event.nativeEvent.pageY,
|
y: event.nativeEvent.pageY,
|
||||||
@@ -106,6 +129,12 @@ export const useGestureDetection = ({
|
|||||||
|
|
||||||
const handleTouchEnd = useCallback(
|
const handleTouchEnd = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
|
// Ignore touch if it started in exclusion zone
|
||||||
|
if (shouldIgnoreTouch.current) {
|
||||||
|
shouldIgnoreTouch.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const touchEndTime = Date.now();
|
const touchEndTime = Date.now();
|
||||||
const touchEndPosition = {
|
const touchEndPosition = {
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
try {
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
const audio = await playerRef.current.getAudioTracks();
|
||||||
setAudioTracks(audio);
|
const subtitles = await playerRef.current.getSubtitleTracks();
|
||||||
setSubtitleTracks(subtitles);
|
setAudioTracks(audio);
|
||||||
|
setSubtitleTracks(subtitles);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +64,24 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|||||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
className='mt-2.5 bg-blue-500 p-2 rounded'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
playerRef.current
|
||||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
.getAudioTracks()
|
||||||
|
.then(setAudioTracks)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
"[VideoDebugInfo] Failed to get audio tracks:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
playerRef.current
|
||||||
|
.getSubtitleTracks()
|
||||||
|
.then(setSubtitleTracks)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(
|
||||||
|
"[VideoDebugInfo] Failed to get subtitle tracks:",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.47.1",
|
"channel": "0.48.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
adjustToNearBlack,
|
adjustToNearBlack,
|
||||||
calculateTextColor,
|
calculateTextColor,
|
||||||
@@ -64,11 +70,13 @@ export const useImageColors = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract colors from the image
|
// Extract colors from the image
|
||||||
getColors(source.uri, {
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then((colors: ImageColorsResult) => {
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
let primary = "#fff";
|
let primary = "#fff";
|
||||||
let text = "#000";
|
let text = "#000";
|
||||||
let backup = "#fff";
|
let backup = "#fff";
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
import type * as ImageColorsType from "react-native-image-colors";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||||
|
const ImageColors = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
adjustToNearBlack,
|
adjustToNearBlack,
|
||||||
calculateTextColor,
|
calculateTextColor,
|
||||||
@@ -80,11 +86,13 @@ export const useImageColorsReturn = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract colors from the image
|
// Extract colors from the image
|
||||||
getColors(source.uri, {
|
if (!ImageColors?.getColors) return;
|
||||||
|
|
||||||
|
ImageColors.getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then((colors: ImageColorsResult) => {
|
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||||
let primary = "#fff";
|
let primary = "#fff";
|
||||||
let text = "#000";
|
let text = "#000";
|
||||||
let backup = "#fff";
|
let backup = "#fff";
|
||||||
|
|||||||
@@ -43,37 +43,14 @@ export const useIntroSkipper = (
|
|||||||
const introTimestamps = segments?.introSegments?.[0];
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[INTRO_SKIPPER] Hook state:`, {
|
|
||||||
itemId,
|
|
||||||
currentTime,
|
|
||||||
hasSegments: !!segments,
|
|
||||||
segments: segments,
|
|
||||||
introSegmentsCount: segments?.introSegments?.length || 0,
|
|
||||||
introSegments: segments?.introSegments,
|
|
||||||
hasIntroTimestamps: !!introTimestamps,
|
|
||||||
introTimestamps,
|
|
||||||
isVlc,
|
|
||||||
isOffline,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (introTimestamps) {
|
if (introTimestamps) {
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
currentTime > introTimestamps.startTime &&
|
currentTime > introTimestamps.startTime &&
|
||||||
currentTime < introTimestamps.endTime;
|
currentTime < introTimestamps.endTime;
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
|
|
||||||
currentTime,
|
|
||||||
introStart: introTimestamps.startTime,
|
|
||||||
introEnd: introTimestamps.endTime,
|
|
||||||
afterStart: currentTime > introTimestamps.startTime,
|
|
||||||
beforeEnd: currentTime < introTimestamps.endTime,
|
|
||||||
shouldShow,
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowSkipButton(shouldShow);
|
setShowSkipButton(shouldShow);
|
||||||
} else {
|
} else {
|
||||||
if (showSkipButton) {
|
if (showSkipButton) {
|
||||||
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
|
||||||
setShowSkipButton(false);
|
setShowSkipButton(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +59,6 @@ export const useIntroSkipper = (
|
|||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
console.log(
|
|
||||||
`[INTRO_SKIPPER] Skipping intro to:`,
|
|
||||||
introTimestamps.endTime,
|
|
||||||
);
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
wrappedSeek(introTimestamps.endTime);
|
wrappedSeek(introTimestamps.endTime);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -96,7 +69,5 @@ export const useIntroSkipper = (
|
|||||||
}
|
}
|
||||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,54 @@
|
|||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
// Helper to exclude specific fields
|
||||||
|
export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||||
|
return Object.values(ItemFields).filter(
|
||||||
|
(field) => !fieldsToExclude.includes(field),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useItemQuery = (
|
||||||
|
itemId: string | undefined,
|
||||||
|
isOffline?: boolean,
|
||||||
|
fields?: ItemFields[],
|
||||||
|
excludeFields?: ItemFields[],
|
||||||
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
|
|
||||||
|
// Calculate final fields: use excludeFields if provided, otherwise use fields
|
||||||
|
const finalFields = excludeFields
|
||||||
|
? Object.values(ItemFields).filter(
|
||||||
|
(field) => !excludeFields.includes(field),
|
||||||
|
)
|
||||||
|
: fields;
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId, finalFields],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!itemId) throw new Error("Item ID is required");
|
||||||
|
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
return getDownloadedItemById(itemId)?.item;
|
return getDownloadedItemById(itemId)?.item;
|
||||||
}
|
}
|
||||||
if (!api || !user || !itemId) return null;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
if (!api || !user) return null;
|
||||||
itemId: itemId,
|
|
||||||
userId: user?.Id,
|
const response = await getItemsApi(api).getItems({
|
||||||
|
ids: [itemId],
|
||||||
|
userId: user.Id,
|
||||||
|
...(finalFields && { fields: finalFields }),
|
||||||
});
|
});
|
||||||
return res.data;
|
|
||||||
|
return response.data.Items?.[0];
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
enabled: !!itemId,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
|||||||
@@ -244,6 +244,22 @@ export class JellyseerrApi {
|
|||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async approveRequest(requestId: number): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(
|
||||||
|
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async declineRequest(requestId: number): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(
|
||||||
|
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
async requests(
|
async requests(
|
||||||
params = {
|
params = {
|
||||||
filter: "all",
|
filter: "all",
|
||||||
@@ -512,7 +528,7 @@ export const useJellyseerr = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
const jellyseerrRegion = useMemo(
|
||||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
[jellyseerrUser],
|
[jellyseerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import {
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
addOrientationChangeListener,
|
||||||
|
getOrientationAsync,
|
||||||
|
lockAsync,
|
||||||
|
Orientation as OrientationEnum,
|
||||||
|
OrientationLock,
|
||||||
|
unlockAsync,
|
||||||
|
} from "@/packages/expo-screen-orientation";
|
||||||
import { Orientation } from "../packages/expo-screen-orientation.tv";
|
import { Orientation } from "../packages/expo-screen-orientation.tv";
|
||||||
|
|
||||||
const orientationToOrientationLock = (
|
const orientationToOrientationLock = (
|
||||||
orientation: Orientation,
|
orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum],
|
||||||
): OrientationLock => {
|
): (typeof OrientationLock)[keyof typeof OrientationLock] => {
|
||||||
switch (orientation) {
|
switch (orientation) {
|
||||||
case Orientation.LANDSCAPE_LEFT:
|
case Orientation.LANDSCAPE_LEFT:
|
||||||
return OrientationLock.LANDSCAPE_LEFT;
|
return OrientationLock.LANDSCAPE_LEFT;
|
||||||
@@ -21,44 +28,52 @@ const orientationToOrientationLock = (
|
|||||||
|
|
||||||
export const useOrientation = () => {
|
export const useOrientation = () => {
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
Platform.isTV
|
Platform.isTV ? OrientationLock.LANDSCAPE : OrientationLock.UNKNOWN,
|
||||||
? ScreenOrientation.OrientationLock.LANDSCAPE
|
|
||||||
: ScreenOrientation.OrientationLock.UNKNOWN,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const orientationSubscription =
|
const orientationSubscription = addOrientationChangeListener(
|
||||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
(event: OrientationChangeEvent) => {
|
||||||
setOrientation(
|
setOrientation(
|
||||||
orientationToOrientationLock(event.orientationInfo.orientation),
|
orientationToOrientationLock(event.orientationInfo.orientation),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
getOrientationAsync().then(
|
||||||
setOrientation(orientationToOrientationLock(orientation));
|
(orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum]) => {
|
||||||
});
|
setOrientation(orientationToOrientationLock(orientation));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
orientationSubscription.remove();
|
orientationSubscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lockOrientation = async (lock: OrientationLock) => {
|
const lockOrientation = async (
|
||||||
|
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
|
||||||
|
) => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
|
if (lock === OrientationLock.DEFAULT) {
|
||||||
await ScreenOrientation.unlockAsync();
|
await unlockAsync();
|
||||||
} else {
|
} else {
|
||||||
await ScreenOrientation.lockAsync(lock);
|
await lockAsync(lock);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlockOrientation = async () => {
|
const unlockOrientationFn = async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
await ScreenOrientation.unlockAsync();
|
await unlockAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
return { orientation, setOrientation, lockOrientation, unlockOrientation };
|
return {
|
||||||
|
orientation,
|
||||||
|
setOrientation,
|
||||||
|
lockOrientation,
|
||||||
|
unlockOrientation: unlockOrientationFn,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export type ChapterInfo = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NowPlayingMetadata = {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
artworkUri?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
export type VlcPlayerViewProps = {
|
||||||
source: VlcPlayerSource;
|
source: VlcPlayerSource;
|
||||||
style?: ViewStyle | ViewStyle[];
|
style?: ViewStyle | ViewStyle[];
|
||||||
@@ -67,6 +74,7 @@ export type VlcPlayerViewProps = {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
videoAspectRatio?: string;
|
videoAspectRatio?: string;
|
||||||
|
nowPlayingMetadata?: NowPlayingMetadata;
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted,
|
muted,
|
||||||
volume,
|
volume,
|
||||||
videoAspectRatio,
|
videoAspectRatio,
|
||||||
|
nowPlayingMetadata,
|
||||||
onVideoLoadStart,
|
onVideoLoadStart,
|
||||||
onVideoStateChange,
|
onVideoStateChange,
|
||||||
onVideoProgress,
|
onVideoProgress,
|
||||||
@@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
|||||||
muted={muted}
|
muted={muted}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
videoAspectRatio={videoAspectRatio}
|
videoAspectRatio={videoAspectRatio}
|
||||||
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onVideoLoadStart={onVideoLoadStart}
|
onVideoLoadStart={onVideoLoadStart}
|
||||||
onVideoLoadEnd={onVideoLoadEnd}
|
onVideoLoadEnd={onVideoLoadEnd}
|
||||||
onVideoStateChange={onVideoStateChange}
|
onVideoStateChange={onVideoStateChange}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class VlcPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
|
||||||
|
if let metadata = metadata {
|
||||||
|
view.setNowPlayingMetadata(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Events(
|
Events(
|
||||||
"onPlaybackStateChanged",
|
"onPlaybackStateChanged",
|
||||||
"onVideoStateChange",
|
"onVideoStateChange",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import MediaPlayer
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
import TVVLCKit
|
import TVVLCKit
|
||||||
@@ -24,6 +26,9 @@ class VlcPlayerView: ExpoView {
|
|||||||
var hasSource = false
|
var hasSource = false
|
||||||
var isTranscoding = false
|
var isTranscoding = false
|
||||||
private var initialSeekPerformed: Bool = false
|
private var initialSeekPerformed: Bool = false
|
||||||
|
private var nowPlayingMetadata: [String: String]?
|
||||||
|
private var artworkImage: UIImage?
|
||||||
|
private var artworkDownloadTask: URLSessionDataTask?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -31,6 +36,8 @@ class VlcPlayerView: ExpoView {
|
|||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupView()
|
setupView()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
|
setupRemoteCommandCenter()
|
||||||
|
setupAudioSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
@@ -60,42 +67,205 @@ class VlcPlayerView: ExpoView {
|
|||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(applicationDidBecomeActive),
|
self, selector: #selector(applicationDidBecomeActive),
|
||||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleAudioSessionInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupAudioSession() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
print("Audio session configured for media controls")
|
||||||
|
} catch {
|
||||||
|
print("Failed to setup audio session: \(error)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRemoteCommandCenter() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
// Play command
|
||||||
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||||
|
self?.play()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause command
|
||||||
|
commandCenter.pauseCommand.isEnabled = true
|
||||||
|
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
self?.pause()
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle play/pause command
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||||
|
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.isPlaying {
|
||||||
|
self.pause()
|
||||||
|
} else {
|
||||||
|
self.play()
|
||||||
|
}
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek forward command
|
||||||
|
commandCenter.skipForwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
||||||
|
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||||
|
let currentTime = player.time.intValue
|
||||||
|
self.seekTo(currentTime + Int32(skipInterval * 1000))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek backward command
|
||||||
|
commandCenter.skipBackwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||||
|
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self, let player = self.mediaPlayer else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
|
||||||
|
let currentTime = player.time.intValue
|
||||||
|
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change playback position command (scrubbing)
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||||
|
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
guard let self = self,
|
||||||
|
let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let positionTime = event.positionTime
|
||||||
|
self.seekTo(Int32(positionTime * 1000))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Remote command center configured")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupRemoteCommandCenter() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
let commandCenter = MPRemoteCommandCenter.shared()
|
||||||
|
|
||||||
|
// Remove all command targets to prevent memory leaks
|
||||||
|
commandCenter.playCommand.removeTarget(nil)
|
||||||
|
commandCenter.pauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||||
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
|
|
||||||
|
// Disable commands
|
||||||
|
commandCenter.playCommand.isEnabled = false
|
||||||
|
commandCenter.pauseCommand.isEnabled = false
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = false
|
||||||
|
commandCenter.skipForwardCommand.isEnabled = false
|
||||||
|
commandCenter.skipBackwardCommand.isEnabled = false
|
||||||
|
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
||||||
|
|
||||||
|
print("Remote command center cleaned up")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func startPictureInPicture() {}
|
func startPictureInPicture() {}
|
||||||
|
|
||||||
@objc func play() {
|
@objc func play() {
|
||||||
self.mediaPlayer?.play()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = false
|
self.mediaPlayer?.play()
|
||||||
print("Play")
|
self.isPaused = false
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
print("Play")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func pause() {
|
@objc func pause() {
|
||||||
self.mediaPlayer?.pause()
|
DispatchQueue.main.async {
|
||||||
self.isPaused = true
|
self.mediaPlayer?.pause()
|
||||||
|
self.isPaused = true
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||||
|
#if !os(tvOS)
|
||||||
|
guard let userInfo = notification.userInfo,
|
||||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
// Interruption began - pause the video
|
||||||
|
print("Audio session interrupted - pausing video")
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
case .ended:
|
||||||
|
// Interruption ended - check if we should resume
|
||||||
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
print("Audio session interruption ended - can resume")
|
||||||
|
// Don't auto-resume - let user manually resume playback
|
||||||
|
} else {
|
||||||
|
print("Audio session interruption ended - should not resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
@objc func seekTo(_ time: Int32) {
|
||||||
guard let player = self.mediaPlayer else { return }
|
DispatchQueue.main.async {
|
||||||
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
let wasPlaying = player.isPlaying
|
||||||
if wasPlaying {
|
|
||||||
self.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.play()
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let duration = player.media?.length.intValue {
|
||||||
|
print("Seeking to time: \(time) Video Duration \(duration)")
|
||||||
|
|
||||||
|
// If the specified time is greater than the duration, seek to the end
|
||||||
|
let seekTime = time > duration ? duration - 1000 : time
|
||||||
|
player.time = VLCTime(int: seekTime)
|
||||||
|
if wasPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
self.updatePlayerState()
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
} else {
|
||||||
|
print("Error: Unable to retrieve video duration")
|
||||||
}
|
}
|
||||||
self.updatePlayerState()
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +433,55 @@ class VlcPlayerView: ExpoView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
|
||||||
|
// Cancel any existing artwork download to prevent race conditions
|
||||||
|
artworkDownloadTask?.cancel()
|
||||||
|
artworkDownloadTask = nil
|
||||||
|
|
||||||
|
self.nowPlayingMetadata = metadata
|
||||||
|
print("[NowPlaying] Metadata received: \(metadata)")
|
||||||
|
|
||||||
|
// Load artwork asynchronously if provided
|
||||||
|
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
|
||||||
|
print("[NowPlaying] Loading artwork from: \(artworkUri)")
|
||||||
|
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if let error = error as NSError?, error.code == NSURLErrorCancelled {
|
||||||
|
print("[NowPlaying] Artwork download cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
print("[NowPlaying] Artwork loading error: \(error)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
} else if let data = data, let image = UIImage(data: data) {
|
||||||
|
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
|
||||||
|
self.artworkImage = image
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[NowPlaying] Failed to create image from data")
|
||||||
|
// Update Now Playing info without artwork on failure
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artworkDownloadTask?.resume()
|
||||||
|
} else {
|
||||||
|
// No artwork URI provided - update immediately
|
||||||
|
print("[NowPlaying] No artwork URI provided")
|
||||||
|
artworkImage = nil
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
@objc func stop(completion: (() -> Void)? = nil) {
|
||||||
guard !isStopping else {
|
guard !isStopping else {
|
||||||
completion?()
|
completion?()
|
||||||
@@ -294,6 +513,27 @@ class VlcPlayerView: ExpoView {
|
|||||||
// Stop the media player
|
// Stop the media player
|
||||||
mediaPlayer?.stop()
|
mediaPlayer?.stop()
|
||||||
|
|
||||||
|
// Cancel any in-flight artwork downloads
|
||||||
|
artworkDownloadTask?.cancel()
|
||||||
|
artworkDownloadTask = nil
|
||||||
|
artworkImage = nil
|
||||||
|
|
||||||
|
// Cleanup remote command center targets
|
||||||
|
cleanupRemoteCommandCenter()
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
// Deactivate audio session to allow other apps to use audio
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
print("Audio session deactivated")
|
||||||
|
} catch {
|
||||||
|
print("Failed to deactivate audio session: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Now Playing info
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
#endif
|
||||||
|
|
||||||
// Remove observer
|
// Remove observer
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
|
||||||
@@ -327,6 +567,60 @@ class VlcPlayerView: ExpoView {
|
|||||||
"duration": durationMs,
|
"duration": durationMs,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Now Playing info to sync elapsed playback time
|
||||||
|
// iOS needs periodic updates to keep progress indicator in sync
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateNowPlayingInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNowPlayingInfo() {
|
||||||
|
#if !os(tvOS)
|
||||||
|
guard let player = self.mediaPlayer else { return }
|
||||||
|
|
||||||
|
var nowPlayingInfo = [String: Any]()
|
||||||
|
|
||||||
|
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
|
||||||
|
|
||||||
|
// Current playback time in seconds
|
||||||
|
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
|
||||||
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
|
||||||
|
|
||||||
|
// Total duration in seconds
|
||||||
|
if let duration = player.media?.length.intValue {
|
||||||
|
let durationSeconds = Double(duration) / 1000.0
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata if available
|
||||||
|
if let metadata = self.nowPlayingMetadata {
|
||||||
|
if let title = metadata["title"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyTitle] = title
|
||||||
|
print("[NowPlaying] Setting title: \(title)")
|
||||||
|
}
|
||||||
|
if let artist = metadata["artist"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
|
||||||
|
print("[NowPlaying] Setting artist: \(artist)")
|
||||||
|
}
|
||||||
|
if let albumTitle = metadata["albumTitle"] {
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
|
||||||
|
print("[NowPlaying] Setting album: \(albumTitle)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add artwork if available
|
||||||
|
if let artwork = self.artworkImage {
|
||||||
|
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
|
||||||
|
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
|
||||||
|
return artwork
|
||||||
|
}
|
||||||
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
|
||||||
|
}
|
||||||
|
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Expo Events
|
// MARK: - Expo Events
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -68,14 +68,14 @@
|
|||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "16.0.0",
|
||||||
"react-native": "npm:react-native-tvos@0.81.5-1",
|
"react-native": "npm:react-native-tvos@0.81.5-1",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^1.0.2",
|
"react-native-bottom-tabs": "^1.0.2",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
"react-native-device-info": "^14.0.4",
|
"react-native-device-info": "^15.0.0",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-google-cast": "^4.9.1",
|
"react-native-google-cast": "^4.9.1",
|
||||||
@@ -103,21 +103,20 @@
|
|||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "7.28.5",
|
||||||
"@biomejs/biome": "^2.3.5",
|
"@biomejs/biome": "2.3.5",
|
||||||
"@react-native-community/cli": "^20.0.0",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.1.10",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "^1.17.0",
|
"expo-doctor": "1.17.11",
|
||||||
"husky": "^9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "^16.1.5",
|
"lint-staged": "16.2.6",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
"doctor": {
|
"doctor": {
|
||||||
|
|||||||
@@ -1 +1,76 @@
|
|||||||
export * from "expo-screen-orientation";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Dummy exports for TV
|
||||||
|
enum DummyOrientationLock {
|
||||||
|
DEFAULT = 0,
|
||||||
|
ALL = 1,
|
||||||
|
PORTRAIT = 2,
|
||||||
|
PORTRAIT_UP = 3,
|
||||||
|
PORTRAIT_DOWN = 4,
|
||||||
|
LANDSCAPE = 5,
|
||||||
|
LANDSCAPE_LEFT = 6,
|
||||||
|
LANDSCAPE_RIGHT = 7,
|
||||||
|
OTHER = 8,
|
||||||
|
UNKNOWN = 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DummyOrientation {
|
||||||
|
UNKNOWN = 0,
|
||||||
|
PORTRAIT_UP = 1,
|
||||||
|
PORTRAIT_DOWN = 2,
|
||||||
|
LANDSCAPE_LEFT = 3,
|
||||||
|
LANDSCAPE_RIGHT = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyLockAsync = async () => {};
|
||||||
|
const dummyUnlockAsync = async () => {};
|
||||||
|
const dummyGetOrientationAsync = async () => DummyOrientation.UNKNOWN;
|
||||||
|
const dummyGetOrientationLockAsync = async () => DummyOrientationLock.DEFAULT;
|
||||||
|
const dummySupportsOrientationLockAsync = async () => false;
|
||||||
|
|
||||||
|
// Conditionally export based on platform
|
||||||
|
let ScreenOrientation: any;
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
ScreenOrientation = require("expo-screen-orientation");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrientationLock = Platform.isTV
|
||||||
|
? DummyOrientationLock
|
||||||
|
: ScreenOrientation?.OrientationLock;
|
||||||
|
export const Orientation = Platform.isTV
|
||||||
|
? DummyOrientation
|
||||||
|
: ScreenOrientation?.Orientation;
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type OrientationLockType = typeof OrientationLock;
|
||||||
|
export type OrientationType = typeof Orientation;
|
||||||
|
export const lockAsync = Platform.isTV
|
||||||
|
? dummyLockAsync
|
||||||
|
: ScreenOrientation?.lockAsync;
|
||||||
|
export const unlockAsync = Platform.isTV
|
||||||
|
? dummyUnlockAsync
|
||||||
|
: ScreenOrientation?.unlockAsync;
|
||||||
|
export const getOrientationAsync = Platform.isTV
|
||||||
|
? dummyGetOrientationAsync
|
||||||
|
: ScreenOrientation?.getOrientationAsync;
|
||||||
|
export const getOrientationLockAsync = Platform.isTV
|
||||||
|
? dummyGetOrientationLockAsync
|
||||||
|
: ScreenOrientation?.getOrientationLockAsync;
|
||||||
|
export const supportsOrientationLockAsync = Platform.isTV
|
||||||
|
? dummySupportsOrientationLockAsync
|
||||||
|
: ScreenOrientation?.supportsOrientationLockAsync;
|
||||||
|
export const lockPlatformAsync = Platform.isTV
|
||||||
|
? dummyLockAsync
|
||||||
|
: ScreenOrientation?.lockPlatformAsync;
|
||||||
|
export const getPlatformLockAsync = Platform.isTV
|
||||||
|
? dummyGetOrientationLockAsync
|
||||||
|
: ScreenOrientation?.getPlatformLockAsync;
|
||||||
|
export const addOrientationChangeListener = Platform.isTV
|
||||||
|
? () => ({ remove: () => {} })
|
||||||
|
: ScreenOrientation?.addOrientationChangeListener;
|
||||||
|
export const removeOrientationChangeListener = Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: ScreenOrientation?.removeOrientationChangeListener;
|
||||||
|
export const removeOrientationChangeListeners = Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: ScreenOrientation?.removeOrientationChangeListeners;
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
/** @param {import("expo/config-plugins").ExpoConfig} config */
|
|
||||||
function withRNBackgroundDownloader(config) {
|
|
||||||
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
|
||||||
config = withAppDelegate(config, (mod) => {
|
|
||||||
const tag = "handleEventsForBackgroundURLSession";
|
|
||||||
if (!mod.modResults.contents.includes(tag)) {
|
|
||||||
mod.modResults.contents = mod.modResults.contents.replace(
|
|
||||||
/\}\s*$/, // insert before final }
|
|
||||||
`
|
|
||||||
func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
handleEventsForBackgroundURLSession identifier: String,
|
|
||||||
completionHandler: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return mod;
|
|
||||||
});
|
|
||||||
|
|
||||||
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
|
||||||
config = withXcodeProject(config, (mod) => {
|
|
||||||
const project = mod.modResults;
|
|
||||||
const projectName = config.name || "App";
|
|
||||||
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
|
||||||
const iosDir = path.dirname(path.dirname(project.filepath));
|
|
||||||
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
|
||||||
const headerAbs = path.join(iosDir, headerRel);
|
|
||||||
|
|
||||||
// create / append import if missing
|
|
||||||
let headerText = "";
|
|
||||||
try {
|
|
||||||
headerText = fs.readFileSync(headerAbs, "utf8");
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
|
||||||
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
|
||||||
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
|
||||||
// Setting the property once at the project level is sufficient.
|
|
||||||
["Debug", "Release"].forEach((cfg) => {
|
|
||||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
|
||||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
|
||||||
project.updateBuildProperty(
|
|
||||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
|
||||||
bridgingHeaderPath,
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return mod;
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withRNBackgroundDownloader;
|
|
||||||
@@ -58,31 +58,42 @@ function useDownloadProvider() {
|
|||||||
| Partial<JobStatus>
|
| Partial<JobStatus>
|
||||||
| ((current: JobStatus) => Partial<JobStatus>),
|
| ((current: JobStatus) => Partial<JobStatus>),
|
||||||
) => {
|
) => {
|
||||||
setProcesses((prev) =>
|
setProcesses((prev) => {
|
||||||
prev.map((p) => {
|
const processIndex = prev.findIndex((p) => p.id === processId);
|
||||||
if (p.id !== processId) return p;
|
if (processIndex === -1) return prev;
|
||||||
const newStatus =
|
|
||||||
typeof updater === "function" ? updater(p) : updater;
|
const currentProcess = prev[processIndex];
|
||||||
return {
|
if (!currentProcess) return prev;
|
||||||
...p,
|
|
||||||
...newStatus,
|
const newStatus =
|
||||||
};
|
typeof updater === "function" ? updater(currentProcess) : updater;
|
||||||
}),
|
|
||||||
);
|
// Create new array with updated process
|
||||||
|
const newProcesses = [...prev];
|
||||||
|
newProcesses[processIndex] = {
|
||||||
|
...currentProcess,
|
||||||
|
...newStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newProcesses;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[setProcesses],
|
[setProcesses],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeProcess = useCallback(
|
const removeProcess = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
// Use setTimeout to defer removal and avoid race conditions during rendering
|
||||||
|
setTimeout(() => {
|
||||||
|
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
||||||
|
|
||||||
// Find and remove from task map
|
// Find and remove from task map
|
||||||
taskMapRef.current.forEach((processId, taskId) => {
|
taskMapRef.current.forEach((processId, taskId) => {
|
||||||
if (processId === id) {
|
if (processId === id) {
|
||||||
taskMapRef.current.delete(taskId);
|
taskMapRef.current.delete(taskId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, 0);
|
||||||
},
|
},
|
||||||
[setProcesses],
|
[setProcesses],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Notifications from "expo-notifications";
|
import type * as NotificationsType from "expo-notifications";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Conditionally import expo-notifications only on non-TV platforms
|
||||||
|
const Notifications = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("expo-notifications") as typeof NotificationsType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate notification content based on item type
|
* Generate notification content based on item type
|
||||||
*/
|
*/
|
||||||
@@ -60,7 +65,7 @@ export async function sendDownloadNotification(
|
|||||||
body: string,
|
body: string,
|
||||||
data?: Record<string, any>,
|
data?: Record<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV || !Notifications) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Notifications.scheduleNotificationAsync({
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.47.1" },
|
clientInfo: { name: "Streamyfin", version: "0.48.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.47.1"`,
|
}, DeviceId="${deviceId}", Version="0.48.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,51 @@
|
|||||||
|
|
||||||
const isTV = process.env?.EXPO_TV === "1";
|
const isTV = process.env?.EXPO_TV === "1";
|
||||||
|
|
||||||
module.exports = {
|
const disableForTV = (_moduleName) =>
|
||||||
dependencies: {
|
isTV
|
||||||
"react-native-volume-manager": !isTV
|
? {
|
||||||
? {
|
platforms: {
|
||||||
platforms: {
|
ios: null,
|
||||||
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
android: null,
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
platforms: {
|
|
||||||
android: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const dependencies = {
|
||||||
|
"react-native-volume-manager": !isTV
|
||||||
|
? {
|
||||||
|
platforms: {
|
||||||
|
// leaving this blank seems to enable auto-linking which is what we want for mobile
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
platforms: {
|
||||||
|
android: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"expo-notifications": disableForTV("expo-notifications"),
|
||||||
|
"react-native-image-colors": disableForTV("react-native-image-colors"),
|
||||||
|
"expo-sharing": disableForTV("expo-sharing"),
|
||||||
|
"expo-haptics": disableForTV("expo-haptics"),
|
||||||
|
"expo-brightness": disableForTV("expo-brightness"),
|
||||||
|
"expo-sensors": disableForTV("expo-sensors"),
|
||||||
|
"expo-screen-orientation": disableForTV("expo-screen-orientation"),
|
||||||
|
"react-native-ios-context-menu": disableForTV(
|
||||||
|
"react-native-ios-context-menu",
|
||||||
|
),
|
||||||
|
"react-native-ios-utilities": disableForTV("react-native-ios-utilities"),
|
||||||
|
"react-native-pager-view": disableForTV("react-native-pager-view"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out undefined values
|
||||||
|
const cleanDependencies = Object.fromEntries(
|
||||||
|
Object.entries(dependencies).filter(([_, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dependencies: cleanDependencies,
|
||||||
|
project: {
|
||||||
|
ios: {},
|
||||||
|
android: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,6 +113,15 @@
|
|||||||
"right_side_volume": "Right Side Volume Control",
|
"right_side_volume": "Right Side Volume Control",
|
||||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
|
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
|
||||||
},
|
},
|
||||||
|
"controls": {
|
||||||
|
"controls_title": "Controls",
|
||||||
|
"show_volume_slider": "Show Volume Slider",
|
||||||
|
"show_volume_slider_description": "Display volume slider on the right side of video controls",
|
||||||
|
"show_brightness_slider": "Show Brightness Slider",
|
||||||
|
"show_brightness_slider_description": "Display brightness slider on the left side of video controls",
|
||||||
|
"show_seek_buttons": "Show Seek Buttons",
|
||||||
|
"show_seek_buttons_description": "Display forward/rewind buttons next to the play button"
|
||||||
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
"set_audio_track": "Set Audio Track From Previous Item",
|
"set_audio_track": "Set Audio Track From Previous Item",
|
||||||
@@ -337,7 +346,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"track": "Track"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
@@ -428,7 +438,12 @@
|
|||||||
"playback_state": "Playback State:",
|
"playback_state": "Playback State:",
|
||||||
"index": "Index:",
|
"index": "Index:",
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
"go_back": "Go Back"
|
"go_back": "Go Back",
|
||||||
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
|
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||||
|
"downloaded_file_yes": "Yes",
|
||||||
|
"downloaded_file_no": "No",
|
||||||
|
"downloaded_file_cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
@@ -444,6 +459,7 @@
|
|||||||
"no_similar_items_found": "No Similar Items Found",
|
"no_similar_items_found": "No Similar Items Found",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"more_details": "More Details",
|
"more_details": "More Details",
|
||||||
|
"media_options": "Media Options",
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtitle",
|
"subtitles": "Subtitle",
|
||||||
@@ -512,6 +528,10 @@
|
|||||||
"number_episodes": "{{episode_number}} Episodes",
|
"number_episodes": "{{episode_number}} Episodes",
|
||||||
"born": "Born",
|
"born": "Born",
|
||||||
"appearances": "Appearances",
|
"appearances": "Appearances",
|
||||||
|
"approve": "Approve",
|
||||||
|
"decline": "Decline",
|
||||||
|
"requested_by": "Requested by {{user}}",
|
||||||
|
"unknown_user": "Unknown User",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
@@ -519,7 +539,11 @@
|
|||||||
"issue_submitted": "Issue Submitted!",
|
"issue_submitted": "Issue Submitted!",
|
||||||
"requested_item": "Requested {{item}}!",
|
"requested_item": "Requested {{item}}!",
|
||||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
||||||
|
"request_approved": "Request Approved!",
|
||||||
|
"request_declined": "Request Declined!",
|
||||||
|
"failed_to_approve_request": "Failed to Approve Request",
|
||||||
|
"failed_to_decline_request": "Failed to Decline Request"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export type DownloadOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ScreenOrientationEnum: Record<
|
export const ScreenOrientationEnum: Record<
|
||||||
ScreenOrientation.OrientationLock,
|
(typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock],
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
[ScreenOrientation.OrientationLock.DEFAULT]:
|
[ScreenOrientation.OrientationLock.DEFAULT]:
|
||||||
@@ -154,7 +154,7 @@ export type Settings = {
|
|||||||
subtitleMode: SubtitlePlaybackMode;
|
subtitleMode: SubtitlePlaybackMode;
|
||||||
rememberSubtitleSelections: boolean;
|
rememberSubtitleSelections: boolean;
|
||||||
showHomeTitles: boolean;
|
showHomeTitles: boolean;
|
||||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
defaultVideoOrientation: (typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock];
|
||||||
forwardSkipTime: number;
|
forwardSkipTime: number;
|
||||||
rewindSkipTime: number;
|
rewindSkipTime: number;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
@@ -180,6 +180,10 @@ export type Settings = {
|
|||||||
enableRightSideVolumeSwipe: boolean;
|
enableRightSideVolumeSwipe: boolean;
|
||||||
usePopularPlugin: boolean;
|
usePopularPlugin: boolean;
|
||||||
showLargeHomeCarousel: boolean;
|
showLargeHomeCarousel: boolean;
|
||||||
|
// Controls
|
||||||
|
showVolumeSlider: boolean;
|
||||||
|
showBrightnessSlider: boolean;
|
||||||
|
showSeekButtons: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Lockable<T> {
|
export interface Lockable<T> {
|
||||||
@@ -244,6 +248,10 @@ export const defaultValues: Settings = {
|
|||||||
enableRightSideVolumeSwipe: true,
|
enableRightSideVolumeSwipe: true,
|
||||||
usePopularPlugin: true,
|
usePopularPlugin: true,
|
||||||
showLargeHomeCarousel: false,
|
showLargeHomeCarousel: false,
|
||||||
|
// Controls
|
||||||
|
showVolumeSlider: true,
|
||||||
|
showBrightnessSlider: true,
|
||||||
|
showSeekButtons: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Partial<Settings> => {
|
const loadSettings = (): Partial<Settings> => {
|
||||||
|
|||||||
Submodule utils/jellyseerr updated: fc6a9e952c...4401b16414
Reference in New Issue
Block a user