mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 08:08:18 +00:00
Compare commits
38 Commits
fix/remove
...
mpv-player
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1769cbd59 | ||
|
|
2b0e238799 | ||
|
|
b573a25203 | ||
|
|
074222050a | ||
|
|
7135be198a | ||
|
|
2648877eb8 | ||
|
|
bc78346760 | ||
|
|
c76d7eb877 | ||
|
|
36655bba43 | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
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 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- 0.29.0
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
@@ -116,4 +111,4 @@ body:
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional context that might help us understand and reproduce the issue.
|
||||
description: Any additional context that might help us understand and reproduce the issue.
|
||||
|
||||
46
.github/renovate.json
vendored
Normal file
46
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"description": "Renovate configuration for Streamyfin - Expo React Native Jellyfin client",
|
||||
"extends": [
|
||||
"config:best-practices",
|
||||
":dependencyDashboard",
|
||||
":enableVulnerabilityAlertsWithLabel(security)",
|
||||
":semanticCommits",
|
||||
":timezone(Etc/UTC)",
|
||||
"group:testNonMajor",
|
||||
"group:monorepos",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
"customManagers:biomeVersions",
|
||||
":automergeBranch",
|
||||
":automergeRequireAllStatusChecks"
|
||||
],
|
||||
"addLabels": ["dependencies"],
|
||||
"rebaseWhen": "conflicted",
|
||||
"ignorePaths": ["**/node_modules/**"],
|
||||
"ignoreUnstable": true,
|
||||
"minimumReleaseAge": "3 days",
|
||||
"schedule": ["before 6am on Sunday"],
|
||||
"branchPrefix": "renovate/",
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"configMigration": true,
|
||||
"separateMinorPatch": true,
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
.github/workflows/build-apps.yml
vendored
14
.github/workflows/build-apps.yml
vendored
@@ -156,7 +156,7 @@ jobs:
|
||||
|
||||
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'))
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -191,6 +191,11 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
with:
|
||||
xcode-version: "26.0.1"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
@@ -219,7 +224,7 @@ jobs:
|
||||
# Disabled for now - uncomment when ready to 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'))
|
||||
# runs-on: macos-15
|
||||
# runs-on: macos-26
|
||||
# name: 🍎 Build iOS IPA (TV)
|
||||
# permissions:
|
||||
# contents: read
|
||||
@@ -254,6 +259,11 @@ jobs:
|
||||
# - name: 🛠️ Generate project files
|
||||
# run: bun run prebuild:tv
|
||||
#
|
||||
# - name: 🔧 Setup Xcode
|
||||
# uses: maxim-lobanov/setup-xcode@v1
|
||||
# with:
|
||||
# xcode-version: '26.0.1'
|
||||
#
|
||||
# - name: 🏗️ Setup EAS
|
||||
# uses: expo/expo-github-action@main
|
||||
# with:
|
||||
|
||||
4
.github/workflows/ci-codeql.yml
vendored
4
.github/workflows/ci-codeql.yml
vendored
@@ -25,10 +25,6 @@ jobs:
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||
|
||||
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.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnType": false,
|
||||
|
||||
// Language-specific formatters
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[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"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
}
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.47.1",
|
||||
"version": "0.48.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 84,
|
||||
"versionCode": 85,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
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 { 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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -11,6 +11,11 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
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() {
|
||||
const navigation = useNavigation();
|
||||
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
|
||||
const share = useCallback(async () => {
|
||||
if (!Sharing) return;
|
||||
|
||||
const logsFile = new File(Paths.document, "logs.txt");
|
||||
|
||||
setLoading(true);
|
||||
@@ -60,9 +67,11 @@ export default function Page() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filteredLogs]);
|
||||
}, [filteredLogs, Sharing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
loading ? (
|
||||
|
||||
@@ -20,7 +20,8 @@ const Page: React.FC = () => {
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
// Fetch item with all fields including MediaSources
|
||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
@@ -33,8 +34,16 @@ import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} 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 {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
MovieResult,
|
||||
@@ -58,7 +67,7 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
@@ -91,6 +100,46 @@ const Page: React.FC = () => {
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
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(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -131,9 +180,11 @@ const Page: React.FC = () => {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: mediaType!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
...(mediaType === MediaType.TV && {
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
@@ -332,6 +383,60 @@ const Page: React.FC = () => {
|
||||
</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' />
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
|
||||
@@ -22,34 +22,34 @@ import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
OutlineThickness,
|
||||
VLC_COLORS,
|
||||
VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import {
|
||||
MpvPlayerView,
|
||||
type MpvPlayerViewRef,
|
||||
type OnPlaybackStateChangePayload,
|
||||
type OnProgressEventPayload,
|
||||
type VideoSource,
|
||||
} from "@/modules";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
@@ -57,7 +57,7 @@ export default function page() {
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [isPipMode, _setIsPipMode] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
@@ -68,6 +68,7 @@ export default function page() {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [tracksReady, setTracksReady] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
@@ -110,7 +111,6 @@ export default function page() {
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
@@ -173,16 +173,6 @@ export default function page() {
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation]);
|
||||
|
||||
interface Stream {
|
||||
mediaSource: MediaSourceInfo;
|
||||
sessionId: string;
|
||||
@@ -229,8 +219,6 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
const native = generateDeviceProfile();
|
||||
const transcoding = generateDeviceProfile({ transcode: true });
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
@@ -240,7 +228,7 @@ export default function page() {
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
@@ -324,7 +312,8 @@ export default function page() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
// MPV doesn't have a stop method, use pause instead
|
||||
videoRef.current?.pause();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
|
||||
@@ -337,6 +326,7 @@ export default function page() {
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
if (!stream || !item?.Id) return;
|
||||
|
||||
return {
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -379,10 +369,13 @@ export default function page() {
|
||||
);
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
async (data: { nativeEvent: OnProgressEventPayload }) => {
|
||||
if (isSeeking.get() || isPlaybackStopped) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
const { position } = data.nativeEvent;
|
||||
// MPV reports position in seconds, convert to ms
|
||||
const currentTime = position * 1000;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
@@ -428,6 +421,46 @@ export default function page() {
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
|
||||
/** Build video source config for the native player */
|
||||
const videoSource = useMemo<VideoSource | undefined>(() => {
|
||||
if (!stream?.url) return undefined;
|
||||
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
|
||||
|
||||
// Calculate MPV track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
||||
|
||||
return {
|
||||
url: stream.url,
|
||||
startPosition,
|
||||
autoplay: true,
|
||||
externalSubtitles: externalSubs,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
};
|
||||
}, [
|
||||
stream?.url,
|
||||
stream?.mediaSource,
|
||||
startPosition,
|
||||
api?.basePath,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -508,10 +541,12 @@ export default function page() {
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback(
|
||||
async (e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
|
||||
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
|
||||
|
||||
if (playing) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
@@ -521,7 +556,7 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
if (isPaused) {
|
||||
setIsPlaying(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
@@ -532,87 +567,13 @@ export default function page() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
if (isLoading) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio",
|
||||
) || [];
|
||||
|
||||
// Move all the external subtitles last, because vlc places them last.
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle",
|
||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
||||
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
/** The text based subtitle tracks */
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
/** The user chosen subtitle track from the server */
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [``];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
|
||||
// Add VLC subtitle styling options from settings
|
||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
||||
"Black") as VLCColor;
|
||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
||||
"Normal") as OutlineThickness;
|
||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings.vlcIsBold ?? false;
|
||||
// Add subtitle styling options
|
||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
||||
initOptions.push(
|
||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
||||
);
|
||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
||||
initOptions.push(
|
||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
||||
);
|
||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
||||
initOptions.push("--sub-margin=40");
|
||||
if (isBold) {
|
||||
initOptions.push("--freetype-bold");
|
||||
}
|
||||
}
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Add useEffect to handle mounting
|
||||
@@ -625,6 +586,7 @@ export default function page() {
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play?.();
|
||||
}, []);
|
||||
@@ -634,46 +596,40 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
videoRef.current?.seekTo?.(position);
|
||||
}, []);
|
||||
const getAudioTracks = useCallback(async () => {
|
||||
return videoRef.current?.getAudioTracks?.() || null;
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
|
||||
const getSubtitleTracks = useCallback(async () => {
|
||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
||||
}, []);
|
||||
// Apply MPV subtitle settings when video loads
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current) return;
|
||||
|
||||
const setSubtitleTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setSubtitleTrack?.(index);
|
||||
}, []);
|
||||
const applySubtitleSettings = async () => {
|
||||
if (settings.mpvSubtitleScale !== undefined) {
|
||||
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
|
||||
}
|
||||
if (settings.mpvSubtitleMarginY !== undefined) {
|
||||
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignX !== undefined) {
|
||||
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
|
||||
}
|
||||
if (settings.mpvSubtitleAlignY !== undefined) {
|
||||
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
|
||||
}
|
||||
if (settings.mpvSubtitleFontSize !== undefined) {
|
||||
await videoRef.current?.setSubtitleFontSize(
|
||||
settings.mpvSubtitleFontSize,
|
||||
);
|
||||
}
|
||||
// Apply subtitle size from general settings
|
||||
if (settings.subtitleSize) {
|
||||
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
|
||||
}
|
||||
};
|
||||
|
||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
||||
// Note: VlcPlayer type only expects url parameter
|
||||
videoRef.current?.setSubtitleURL?.(url);
|
||||
}, []);
|
||||
|
||||
const setAudioTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setAudioTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setVideoAspectRatio = useCallback(
|
||||
async (aspectRatio: string | null) => {
|
||||
return (
|
||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
||||
Promise.resolve()
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
||||
return (
|
||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
||||
);
|
||||
}, []);
|
||||
|
||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||
applySubtitleSettings();
|
||||
}, [isVideoLoaded, settings]);
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
@@ -684,7 +640,7 @@ export default function page() {
|
||||
);
|
||||
}
|
||||
|
||||
// Then show loader while either side is still fetching or data isn’t present
|
||||
// Then show loader while either side is still fetching or data isn't present
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
// …loader UI…
|
||||
return (
|
||||
@@ -702,90 +658,80 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
<PlayerProvider
|
||||
playerRef={videoRef}
|
||||
item={item}
|
||||
mediaSource={stream?.mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
tracksReady={tracksReady}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
<VideoProvider>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onPipStarted={(e) => {
|
||||
setIsPipMode(e.nativeEvent.pipStarted);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{isMounted === true && item && !isPipMode && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
isVlc
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
setTracksReady(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{isMounted === true && item && !isPipMode && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
enableTrickplay={true}
|
||||
offline={offline}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</VideoProvider>
|
||||
</PlayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ const Login: React.FC = () => {
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = 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 [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: _username,
|
||||
password: _password,
|
||||
username: _username || "",
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -264,6 +264,12 @@ const Login: React.FC = () => {
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
@@ -272,6 +278,8 @@ const Login: React.FC = () => {
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
@@ -280,6 +288,12 @@ const Login: React.FC = () => {
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
@@ -289,10 +303,17 @@ const Login: React.FC = () => {
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
<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 className='mt-3'>
|
||||
<Button
|
||||
@@ -334,6 +355,8 @@ const Login: React.FC = () => {
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Full-width primary button */}
|
||||
@@ -394,6 +417,12 @@ const Login: React.FC = () => {
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
@@ -410,6 +439,12 @@ const Login: React.FC = () => {
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
@@ -423,6 +458,7 @@ const Login: React.FC = () => {
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim()}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
|
||||
32
bun.lock
32
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
@@ -51,7 +50,7 @@
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "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-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
@@ -86,21 +85,20 @@
|
||||
"zod": "^4.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@biomejs/biome": "^2.3.5",
|
||||
"@react-native-community/cli": "^20.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@babel/core": "7.28.5",
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@react-native-community/cli": "20.0.2",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.17.11",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.6",
|
||||
"react-test-renderer": "19.1.1",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1561,8 +1559,6 @@
|
||||
|
||||
"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-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=="],
|
||||
@@ -1609,7 +1605,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type React from "react";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -18,6 +17,58 @@ import {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
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
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black" | "transparent";
|
||||
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||
variant?: "solid" | "border";
|
||||
iconRight?: ReactNode;
|
||||
iconLeft?: ReactNode;
|
||||
justify?: "center" | "between";
|
||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
color = "purple",
|
||||
variant = "solid",
|
||||
iconRight,
|
||||
iconLeft,
|
||||
children,
|
||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const colorClasses = useMemo(() => {
|
||||
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 colorClasses = getColorClasses(color, variant, focused);
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const textColorClass =
|
||||
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||
|
||||
return Platform.isTV ? (
|
||||
<Pressable
|
||||
className='w-full'
|
||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
>
|
||||
<View
|
||||
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}`}
|
||||
>
|
||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||
<Text
|
||||
className={`
|
||||
text-white font-bold text-base
|
||||
${textColorClass} font-bold text-base
|
||||
${disabled ? "text-gray-300" : ""}
|
||||
${textClassName}
|
||||
${iconRight ? "mr-2" : ""}
|
||||
|
||||
@@ -132,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
return itemsNotDownloaded.length === 0;
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
() =>
|
||||
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
|
||||
[],
|
||||
[processes, itemIds],
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (itemIds.length === 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
|
||||
return (
|
||||
((itemIds.length -
|
||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||
@@ -262,9 +264,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
closeModal();
|
||||
|
||||
// Wait for modal dismiss animation to complete
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
initiateDownload(...itemsToDownload);
|
||||
});
|
||||
}, 300);
|
||||
} else {
|
||||
toast.error(
|
||||
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 { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// 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 { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { BitrateSheet } from "./BitRateSheet";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
import { TrackSheet } from "./TrackSheet";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
@@ -59,7 +56,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemColors = useImageColorsReturn({ item });
|
||||
|
||||
@@ -75,7 +71,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
@@ -90,7 +86,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -102,7 +98,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
if (!Platform.isTV && item) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
@@ -143,7 +139,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user]);
|
||||
}, [item, navigation, user, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (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 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' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||
<BitrateSheet
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSheet
|
||||
className='mr-1'
|
||||
|
||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<TrackSheet
|
||||
className='mr-1'
|
||||
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" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
@@ -279,9 +226,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
|
||||
193
components/MediaSourceButton.tsx
Normal file
193
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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 { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item?: BaseItemDto | null;
|
||||
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 effectiveColors = colors || {
|
||||
primary: "#7c3aed",
|
||||
text: "#000000",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstMediaSource = item?.MediaSources?.[0];
|
||||
if (!firstMediaSource) return;
|
||||
setSelectedOptions((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
mediaSource: firstMediaSource,
|
||||
};
|
||||
});
|
||||
}, [item, 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 (item?.MediaSources && item.MediaSources.length > 1) {
|
||||
groups.push({
|
||||
title: t("item_card.video"),
|
||||
options: item.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;
|
||||
}, [
|
||||
item,
|
||||
selectedOptions,
|
||||
audioStreams,
|
||||
subtitleStreams,
|
||||
getMediaSourceDisplayName,
|
||||
t,
|
||||
setSelectedOptions,
|
||||
]);
|
||||
|
||||
const trigger = (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
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'>
|
||||
{!item ? (
|
||||
<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,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
@@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({
|
||||
}
|
||||
}, [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") {
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
@@ -24,6 +25,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -33,6 +36,8 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
@@ -55,6 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -84,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -271,6 +274,117 @@ export const PlayButton: React.FC<Props> = ({
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
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(() => {
|
||||
@@ -358,55 +472,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[startColor.value.text, endColor.value.text],
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
// <Host
|
||||
// style={{
|
||||
// height: 50,
|
||||
// flex: 1,
|
||||
// flexShrink: 0,
|
||||
// }}
|
||||
// >
|
||||
// <Button
|
||||
// variant='glassProminent'
|
||||
// onPress={onPress}
|
||||
// color={effectiveColors.primary}
|
||||
// modifiers={[fixedSize()]}
|
||||
// >
|
||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
// {runtimeTicksToMinutes(
|
||||
// (item?.RunTimeTicks || 0) -
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||
// )}
|
||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
// </Animated.Text>
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Ionicons name='play-circle' size={24} />
|
||||
// </Animated.Text>
|
||||
// {client && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Feather name='cast' size={22} />
|
||||
// <CastButton tintColor='transparent' />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// {!client && settings?.openInVLC && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <MaterialCommunityIcons
|
||||
// name='vlc'
|
||||
// size={18}
|
||||
// color={animatedTextStyle.color}
|
||||
// />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// </View>
|
||||
// </Button>
|
||||
// </Host>
|
||||
// );
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -414,7 +479,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
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'>
|
||||
<Animated.View
|
||||
@@ -457,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -17,7 +17,6 @@ import Animated, {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const { settings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -16,7 +16,10 @@ export function Input(props: InputProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
return Platform.isTV ? (
|
||||
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
|
||||
<TouchableOpacity
|
||||
onPress={() => inputRef?.current?.focus?.()}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={`
|
||||
|
||||
@@ -9,7 +9,11 @@ interface ActiveDownloadsProps extends ViewProps {}
|
||||
|
||||
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||
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 (
|
||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
@@ -27,8 +31,8 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||
{t("home.downloads.active_downloads")}
|
||||
</Text>
|
||||
<View className='gap-y-2'>
|
||||
{processes?.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
{validProcesses.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.id} process={p} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
};
|
||||
|
||||
const eta = useMemo(() => {
|
||||
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
||||
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,13 +66,14 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}
|
||||
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
||||
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
|
||||
|
||||
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)
|
||||
if (process.maxBitrate.value) {
|
||||
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
|
||||
return estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
@@ -81,32 +82,43 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
process.estimatedTotalSizeBytes,
|
||||
process?.maxBitrate?.value,
|
||||
process?.item?.RunTimeTicks,
|
||||
process?.estimatedTotalSizeBytes,
|
||||
]);
|
||||
|
||||
const isTranscoding = process.isTranscoding || false;
|
||||
const isTranscoding = process?.isTranscoding || false;
|
||||
|
||||
const downloadedAmount = useMemo(() => {
|
||||
if (!process.bytesDownloaded) return null;
|
||||
if (!process?.bytesDownloaded) return null;
|
||||
return formatBytes(process.bytesDownloaded);
|
||||
}, [process.bytesDownloaded]);
|
||||
}, [process?.bytesDownloaded]);
|
||||
|
||||
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
|
||||
const sanitizedProgress = useMemo(() => {
|
||||
if (
|
||||
typeof process.progress !== "number" ||
|
||||
typeof process?.progress !== "number" ||
|
||||
Number.isNaN(process.progress)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -76,10 +76,6 @@ export const Home = () => {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("scrollY", scrollY);
|
||||
}, [scrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -64,6 +65,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
// Flatten all pages into a single array
|
||||
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 (disabled || !title) return null;
|
||||
|
||||
@@ -126,6 +132,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{allItems.map((item) => (
|
||||
|
||||
@@ -144,11 +144,14 @@ const RequestModal = forwardRef<
|
||||
}, [defaultServiceDetails]);
|
||||
|
||||
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_number", {
|
||||
season_number: requestBody?.seasons,
|
||||
season_number: requestBody.seasons[0],
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
[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;
|
||||
|
||||
return (
|
||||
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["media-list", collection.Id!]}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollRef.current?.scrollToIndex(index, 16);
|
||||
scrollRef.current?.scrollToIndex(index, -16);
|
||||
};
|
||||
|
||||
const seasonId = useMemo(() => {
|
||||
@@ -87,6 +87,11 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}
|
||||
}, [episodes, item]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
|
||||
return episodes?.map((_, index) => index * itemWidth) || [];
|
||||
}, [episodes]);
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
ref={scrollRef}
|
||||
@@ -109,6 +114,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
<ItemCardText item={_item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
type OutlineThickness,
|
||||
VLC_COLORS,
|
||||
type VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -92,84 +86,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
];
|
||||
}, [settings?.subtitleMode, t, updateSettings]);
|
||||
|
||||
const textColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcTextColor || "White") === color,
|
||||
onPress: () => updateSettings({ vlcTextColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcTextColor, t, updateSettings]);
|
||||
|
||||
const backgroundColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcBackgroundColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcBackgroundColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundColor, t, updateSettings]);
|
||||
|
||||
const outlineColorOptionGroups = useMemo(() => {
|
||||
const colors = Object.keys(VLC_COLORS) as VLCColor[];
|
||||
const options = colors.map((color) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.colors.${color}`),
|
||||
value: color,
|
||||
selected: (settings?.vlcOutlineColor || "Black") === color,
|
||||
onPress: () => updateSettings({ vlcOutlineColor: color }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineColor, t, updateSettings]);
|
||||
|
||||
const outlineThicknessOptionGroups = useMemo(() => {
|
||||
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
|
||||
const options = thicknesses.map((thickness) => ({
|
||||
type: "radio" as const,
|
||||
label: t(`home.settings.subtitles.thickness.${thickness}`),
|
||||
value: thickness,
|
||||
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
|
||||
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineThickness, t, updateSettings]);
|
||||
|
||||
const backgroundOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
|
||||
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcBackgroundOpacity, updateSettings]);
|
||||
|
||||
const outlineOpacityOptionGroups = useMemo(() => {
|
||||
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
const options = opacities.map((opacity) => ({
|
||||
type: "radio" as const,
|
||||
label: `${Math.round((opacity / 255) * 100)}%`,
|
||||
value: opacity,
|
||||
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
|
||||
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
|
||||
}));
|
||||
|
||||
return [{ options }];
|
||||
}, [settings?.vlcOutlineOpacity, updateSettings]);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -252,124 +168,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||
<PlatformDropdown
|
||||
groups={textColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.text_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineThicknessOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_thickness")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={backgroundOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||
<PlatformDropdown
|
||||
groups={outlineOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||
<Switch
|
||||
value={settings?.vlcIsBold ?? false}
|
||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
skipIntro: () => void;
|
||||
@@ -66,7 +65,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
skipIntro,
|
||||
@@ -145,13 +143,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
settings.autoPlayEpisodeCount <
|
||||
settings.maxAutoPlayEpisodeCount.value) && (
|
||||
<NextEpisodeCountDownButton
|
||||
show={
|
||||
!nextItem
|
||||
? false
|
||||
: isVlc
|
||||
? remainingTime < 10000
|
||||
: remainingTime < 10
|
||||
}
|
||||
show={!nextItem ? false : remainingTime < 10000}
|
||||
onFinish={handleNextEpisodeAutoPlay}
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
@@ -208,7 +200,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -28,7 +27,6 @@ import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -36,7 +34,6 @@ import { ticksToMs } from "@/utils/time";
|
||||
import { BottomControls } from "./BottomControls";
|
||||
import { CenterControls } from "./CenterControls";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { ControlProvider } from "./contexts/ControlContext";
|
||||
import { EpisodeList } from "./EpisodeList";
|
||||
import { GestureOverlay } from "./GestureOverlay";
|
||||
import { HeaderControls } from "./HeaderControls";
|
||||
@@ -50,36 +47,27 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: () => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setVideoAspectRatio?: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor?: (scaleFactor: number) => Promise<void>;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
setScaleFactor?: Dispatch<SetStateAction<ScaleFactor>>;
|
||||
isVlc?: boolean;
|
||||
api?: Api | null;
|
||||
downloadedFiles?: DownloadedItem[];
|
||||
}
|
||||
@@ -99,12 +87,6 @@ export const Controls: FC<Props> = ({
|
||||
showControls,
|
||||
setShowControls,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setSubtitleURL,
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
setVideoAspectRatio,
|
||||
setVideoScaleFactor,
|
||||
aspectRatio = "default",
|
||||
@@ -112,7 +94,6 @@ export const Controls: FC<Props> = ({
|
||||
setAspectRatio,
|
||||
setScaleFactor,
|
||||
offline = false,
|
||||
isVlc = false,
|
||||
api = null,
|
||||
downloadedFiles = undefined,
|
||||
}) => {
|
||||
@@ -194,17 +175,13 @@ export const Controls: FC<Props> = ({
|
||||
zIndex: 10,
|
||||
}));
|
||||
|
||||
// Initialize progress values
|
||||
// Initialize progress values - MPV uses milliseconds
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = isVlc
|
||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = isVlc
|
||||
? ticksToMs(item.RunTimeTicks || 0)
|
||||
: item.RunTimeTicks || 0;
|
||||
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
|
||||
max.value = ticksToMs(item.RunTimeTicks || 0);
|
||||
}
|
||||
}, [item, isVlc, progress, max]);
|
||||
}, [item, progress, max]);
|
||||
|
||||
// Navigation hooks
|
||||
const {
|
||||
@@ -215,7 +192,6 @@ export const Controls: FC<Props> = ({
|
||||
} = useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
});
|
||||
@@ -225,7 +201,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
});
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
@@ -248,7 +223,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
@@ -273,7 +247,6 @@ export const Controls: FC<Props> = ({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
@@ -302,9 +275,8 @@ export const Controls: FC<Props> = ({
|
||||
: current.actual;
|
||||
} else {
|
||||
// When not scrubbing, only update if progress changed significantly (1 second)
|
||||
const progressUnit = isVlc
|
||||
? CONTROLS_CONSTANTS.PROGRESS_UNIT_MS
|
||||
: CONTROLS_CONSTANTS.PROGRESS_UNIT_TICKS;
|
||||
// MPV uses milliseconds
|
||||
const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS;
|
||||
const progressDiff = Math.abs(current.actual - effectiveProgress.value);
|
||||
if (progressDiff >= progressUnit) {
|
||||
effectiveProgress.value = current.actual;
|
||||
@@ -325,7 +297,6 @@ export const Controls: FC<Props> = ({
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
@@ -336,7 +307,6 @@ export const Controls: FC<Props> = ({
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
@@ -359,12 +329,10 @@ export const Controls: FC<Props> = ({
|
||||
mediaSource: newMediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(
|
||||
item,
|
||||
settings,
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
} = getDefaultPlaySettings(item, settings, {
|
||||
indexes: previousIndexes,
|
||||
source: mediaSource ?? undefined,
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
@@ -479,11 +447,7 @@ export const Controls: FC<Props> = ({
|
||||
}, [isPlaying, togglePlay]);
|
||||
|
||||
return (
|
||||
<ControlProvider
|
||||
item={item}
|
||||
mediaSource={mediaSource}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
>
|
||||
<>
|
||||
{episodeView ? (
|
||||
<EpisodeList
|
||||
item={item}
|
||||
@@ -515,11 +479,6 @@ export const Controls: FC<Props> = ({
|
||||
goToNextItem={goToNextItem}
|
||||
previousItem={previousItem}
|
||||
nextItem={nextItem}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
@@ -554,7 +513,6 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
isVlc={isVlc}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
skipIntro={skipIntro}
|
||||
@@ -582,6 +540,6 @@ export const Controls: FC<Props> = ({
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
||||
)}
|
||||
</ControlProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import { VideoProvider } from "./contexts/VideoContext";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { type ScaleFactor, ScaleFactorSelector } from "./ScaleFactorSelector";
|
||||
import {
|
||||
@@ -34,11 +33,6 @@ interface HeaderControlsProps {
|
||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
||||
previousItem?: BaseItemDto | null;
|
||||
nextItem?: BaseItemDto | null;
|
||||
getAudioTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
getSubtitleTracks?: (() => Promise<any[] | null>) | (() => any[]);
|
||||
setAudioTrack?: (index: number) => void;
|
||||
setSubtitleTrack?: (index: number) => void;
|
||||
setSubtitleURL?: (url: string, customName: string) => void;
|
||||
aspectRatio?: AspectRatio;
|
||||
scaleFactor?: ScaleFactor;
|
||||
setAspectRatio?: Dispatch<SetStateAction<AspectRatio>>;
|
||||
@@ -58,11 +52,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
goToNextItem,
|
||||
previousItem,
|
||||
nextItem,
|
||||
getAudioTracks,
|
||||
getSubtitleTracks,
|
||||
setAudioTrack,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
aspectRatio = "default",
|
||||
scaleFactor = 1.0,
|
||||
setAspectRatio,
|
||||
@@ -113,35 +102,25 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
</VideoProvider>
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV &&
|
||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
||||
Platform.OS === "android") && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='picture-in-picture'
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!Platform.isTV && startPictureInPicture && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
>
|
||||
<MaterialIcons
|
||||
name='picture-in-picture'
|
||||
size={ICON_SIZES.HEADER}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={switchOnEpisodeMode}
|
||||
|
||||
@@ -13,12 +13,6 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
||||
buttonText,
|
||||
...props
|
||||
}) => {
|
||||
console.log(`[SKIP_BUTTON] Render:`, {
|
||||
buttonText,
|
||||
showButton,
|
||||
className: showButton ? "flex" : "hidden",
|
||||
});
|
||||
|
||||
return (
|
||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -6,18 +6,20 @@ import { formatTimeString } from "@/utils/time";
|
||||
interface TimeDisplayProps {
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays current time and remaining time.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
isVlc,
|
||||
}) => {
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
const remainingMs = isVlc ? remainingTime : remainingTime * 1000;
|
||||
const finishTime = new Date(now.getTime() + remainingMs);
|
||||
// remainingTime is in ms
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
return finishTime.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -28,11 +30,11 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
return (
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View className='flex flex-col items-end'>
|
||||
<Text className='text-[12px] text-neutral-400'>
|
||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getFinishTime()}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import type React from "react";
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
interface ControlContextProps {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
const ControlContext = createContext<ControlContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ControlProviderProps {
|
||||
children: ReactNode;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean | undefined;
|
||||
}
|
||||
|
||||
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||
children,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
}) => {
|
||||
return (
|
||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||
{children}
|
||||
</ControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useControlContext = () => {
|
||||
const context = useContext(ControlContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useControlContext must be used within a ControlProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
107
components/video-player/controls/contexts/PlayerContext.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, {
|
||||
createContext,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { MpvPlayerViewRef } from "@/modules";
|
||||
|
||||
interface PlayerContextProps {
|
||||
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextProps | undefined>(undefined);
|
||||
|
||||
interface PlayerProviderProps {
|
||||
children: ReactNode;
|
||||
playerRef: MutableRefObject<MpvPlayerViewRef | null>;
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
}
|
||||
|
||||
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
children,
|
||||
playerRef,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
() => ({ playerRef, item, mediaSource, isVideoLoaded, tracksReady }),
|
||||
[playerRef, item, mediaSource, isVideoLoaded, tracksReady],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Core context hook
|
||||
export const usePlayerContext = () => {
|
||||
const context = useContext(PlayerContext);
|
||||
if (!context)
|
||||
throw new Error("usePlayerContext must be used within PlayerProvider");
|
||||
return context;
|
||||
};
|
||||
|
||||
// Player controls hook
|
||||
export const usePlayerControls = () => {
|
||||
const { playerRef } = usePlayerContext();
|
||||
|
||||
return {
|
||||
// Subtitle controls
|
||||
getSubtitleTracks: async () => {
|
||||
return playerRef.current?.getSubtitleTracks() ?? null;
|
||||
},
|
||||
setSubtitleTrack: (trackId: number) => {
|
||||
playerRef.current?.setSubtitleTrack(trackId);
|
||||
},
|
||||
disableSubtitles: () => {
|
||||
playerRef.current?.disableSubtitles();
|
||||
},
|
||||
addSubtitleFile: (url: string, select = true) => {
|
||||
playerRef.current?.addSubtitleFile(url, select);
|
||||
},
|
||||
|
||||
// Audio controls
|
||||
getAudioTracks: async () => {
|
||||
return playerRef.current?.getAudioTracks() ?? null;
|
||||
},
|
||||
setAudioTrack: (trackId: number) => {
|
||||
playerRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
|
||||
// Playback controls
|
||||
play: () => playerRef.current?.play(),
|
||||
pause: () => playerRef.current?.pause(),
|
||||
seekTo: (position: number) => playerRef.current?.seekTo(position),
|
||||
seekBy: (offset: number) => playerRef.current?.seekBy(offset),
|
||||
setSpeed: (speed: number) => playerRef.current?.setSpeed(speed),
|
||||
|
||||
// Subtitle positioning
|
||||
setSubtitleScale: (scale: number) =>
|
||||
playerRef.current?.setSubtitleScale(scale),
|
||||
setSubtitlePosition: (position: number) =>
|
||||
playerRef.current?.setSubtitlePosition(position),
|
||||
setSubtitleMarginY: (margin: number) =>
|
||||
playerRef.current?.setSubtitleMarginY(margin),
|
||||
setSubtitleFontSize: (size: number) =>
|
||||
playerRef.current?.setSubtitleFontSize(size),
|
||||
|
||||
// PiP
|
||||
startPictureInPicture: () => playerRef.current?.startPictureInPicture(),
|
||||
stopPictureInPicture: () => playerRef.current?.stopPictureInPicture(),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,69 @@
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
/**
|
||||
* VideoContext.tsx
|
||||
*
|
||||
* Manages subtitle and audio track state for the video player UI.
|
||||
*
|
||||
* ============================================================================
|
||||
* INDEX TYPES
|
||||
* ============================================================================
|
||||
*
|
||||
* We track two different indices for each track:
|
||||
*
|
||||
* 1. SERVER INDEX (sub.Index / track.index)
|
||||
* - Jellyfin's server-side stream index
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Allows Jellyfin to remember user's last selected tracks
|
||||
* - Passed via router params (subtitleIndex, audioIndex)
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 2. MPV INDEX (track.mpvIndex)
|
||||
* - MPV's internal track ID for the loaded track
|
||||
* - Used to actually switch tracks in the player
|
||||
* - Only assigned to tracks that are loaded into MPV
|
||||
* - Value of -1 means track is not in MPV (e.g., burned-in image sub)
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE DELIVERY METHODS
|
||||
* ============================================================================
|
||||
*
|
||||
* Jellyfin provides subtitles via different delivery methods:
|
||||
* - Embed: Subtitle is embedded in the container (MKV, MP4, etc.)
|
||||
* - Hls: Subtitle is delivered via HLS segments (during transcoding)
|
||||
* - External: Subtitle is delivered as a separate file URL
|
||||
* - Encode: Subtitle is burned into the video (image-based subs during transcode)
|
||||
*
|
||||
* Jellyfin also provides `IsTextSubtitleStream` boolean:
|
||||
* - true: Text-based subtitle (SRT, ASS, VTT, etc.)
|
||||
* - false: Image-based subtitle (PGS, VOBSUB, DVDSUB, etc.)
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE TYPES AND HOW THEY'RE HANDLED
|
||||
* ============================================================================
|
||||
*
|
||||
* 1. TEXT-BASED SUBTITLES (IsTextSubtitleStream = true)
|
||||
* - Direct Play: Loaded into MPV (embedded or via sub-add for external)
|
||||
* - Transcoding: Delivered via HLS, loaded into MPV
|
||||
* - Action: Use playerControls.setSubtitleTrack(mpvId)
|
||||
*
|
||||
* 2. IMAGE-BASED SUBTITLES (IsTextSubtitleStream = false)
|
||||
* - Direct Play: Embedded ones are in MPV, external ones are filtered out
|
||||
* - Transcoding: BURNED INTO VIDEO by Jellyfin (not in MPV track list)
|
||||
* - Action: When transcoding, use replacePlayer() to request burn-in
|
||||
*
|
||||
* ============================================================================
|
||||
* MPV INDEX CALCULATION
|
||||
* ============================================================================
|
||||
*
|
||||
* We iterate through Jellyfin's subtitle list and assign MPV indices only to
|
||||
* subtitles that are actually loaded into MPV:
|
||||
*
|
||||
* - isSubtitleInMpv = true: Subtitle is in MPV's track list, increment index
|
||||
* - isSubtitleInMpv = false: Subtitle is NOT in MPV (e.g., image sub during
|
||||
* transcode), do NOT increment index
|
||||
*
|
||||
* The order of subtitles in Jellyfin's MediaStreams matches the order in MPV.
|
||||
*/
|
||||
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
@@ -9,52 +74,29 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import type { AudioTrack, SubtitleTrack } from "@/modules";
|
||||
import {
|
||||
isImageBasedSubtitle,
|
||||
isSubtitleInMpv,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: Track[] | null;
|
||||
subtitleTracks: Track[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
audioTracks: Track[] | null;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getAudioTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
getSubtitleTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const { tracksReady, mediaSource } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -66,173 +108,143 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(() => {
|
||||
return (
|
||||
allSubs.find(
|
||||
(s) =>
|
||||
s.Index?.toString() === subtitleIndex &&
|
||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||
) || subtitleIndex === "-1"
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
/**
|
||||
* Check if the currently selected subtitle is image-based.
|
||||
* Used to determine if we need to refresh the player when changing subs.
|
||||
*/
|
||||
const isCurrentSubImageBased = useMemo(() => {
|
||||
if (subtitleIndex === "-1") return false;
|
||||
const currentSub = allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex,
|
||||
);
|
||||
return currentSub ? isImageBasedSubtitle(currentSub) : false;
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
chosenSubtitleIndex = subtitleIndex,
|
||||
}: {
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
/**
|
||||
* Refresh the player with new parameters.
|
||||
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||
*/
|
||||
const replacePlayer = (params: {
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
};
|
||||
|
||||
const setTrackParams = (
|
||||
type: "audio" | "subtitle",
|
||||
index: number,
|
||||
serverIndex: number,
|
||||
) => {
|
||||
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
||||
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
||||
|
||||
// If we're transcoding and we're going from a image based subtitle
|
||||
// to a text based subtitle, we need to change the player params.
|
||||
|
||||
const shouldChangePlayerParams =
|
||||
type === "subtitle" &&
|
||||
mediaSource?.TranscodingUrl &&
|
||||
!onTextBasedSubtitle;
|
||||
|
||||
console.log("Set player params", index, serverIndex);
|
||||
if (shouldChangePlayerParams) {
|
||||
setPlayerParams({
|
||||
chosenSubtitleIndex: serverIndex.toString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTrack?.(serverIndex);
|
||||
router.setParams({
|
||||
[paramKey]: serverIndex.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch tracks when ready
|
||||
useEffect(() => {
|
||||
if (!tracksReady) return;
|
||||
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
let subtitleData = await getSubtitleTracks();
|
||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||
if (
|
||||
mediaSource?.TranscodingUrl &&
|
||||
subtitleData &&
|
||||
subtitleData.length > 1
|
||||
) {
|
||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
||||
}
|
||||
const [subtitleData, audioData] = await Promise.all([
|
||||
playerControls.getSubtitleTracks().catch(() => null),
|
||||
playerControls.getAudioTracks().catch(() => null),
|
||||
]);
|
||||
|
||||
let embedSubIndex = 1;
|
||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
||||
const shouldIncrement =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
/** The index of subtitle inside VLC Player Itself */
|
||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
||||
if (shouldIncrement) embedSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
// Process subtitles - map Jellyfin indices to MPV track IDs
|
||||
let mpvIndex = 0; // MPV track index counter (only incremented for subs in MPV)
|
||||
|
||||
// Step 3: Restore the original order
|
||||
const subtitles: Track[] = processedSubs.sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
const subs: Track[] = allSubs.map((sub) => {
|
||||
const inMpv = isSubtitleInMpv(sub, isTranscoding);
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
});
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (getAudioTracks) {
|
||||
const audioData = await getAudioTracks();
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
||||
};
|
||||
// Get MPV track ID: only if this sub is actually in MPV's track list
|
||||
const mpvId = inMpv
|
||||
? ((subtitleData as SubtitleTrack[])?.[mpvIndex++]?.id ?? -1)
|
||||
: -1;
|
||||
|
||||
return {
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1, // Jellyfin server-side index
|
||||
mpvIndex: mpvId, // MPV track ID (-1 if not in MPV)
|
||||
setTrack: () => {
|
||||
// Case 1: Transcoding + switching to/from image-based sub
|
||||
// Need to refresh player so Jellyfin burns in the new sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Subtitle is in MPV - just switch tracks
|
||||
if (inMpv && mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 3: Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add "Disable" option at the beginning
|
||||
subs.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () => {
|
||||
// If currently using image-based sub during transcode, need to refresh
|
||||
if (isTranscoding && isCurrentSubImageBased) {
|
||||
replacePlayer({ subtitleIndex: "-1" });
|
||||
} else {
|
||||
playerControls.setSubtitleTrack(-1);
|
||||
router.setParams({ subtitleIndex: "-1" });
|
||||
}
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Add a "Disable Audio" option if its not transcoding.
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
audioTracks.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () => setTrackParams("audio", -1, -1),
|
||||
});
|
||||
}
|
||||
setAudioTracks(audioTracks);
|
||||
}
|
||||
// Process audio tracks
|
||||
const audio: Track[] = allAudio.map((a, idx) => ({
|
||||
name: a.DisplayTitle || "Unknown",
|
||||
index: a.Index ?? -1,
|
||||
setTrack: () => {
|
||||
// Transcoding: need full player refresh to change audio stream
|
||||
if (isTranscoding) {
|
||||
replacePlayer({ audioIndex: String(a.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct play: just switch audio track in MPV
|
||||
const mpvId = (audioData as AudioTrack[])?.[idx]?.id ?? idx + 1;
|
||||
playerControls.setAudioTrack(mpvId);
|
||||
router.setParams({ audioIndex: String(a.Index) });
|
||||
},
|
||||
}));
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||
}, [tracksReady, mediaSource]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider
|
||||
value={{
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}}
|
||||
>
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||
}
|
||||
return context;
|
||||
const ctx = useContext(VideoContext);
|
||||
if (!ctx)
|
||||
throw new Error("useVideoContext must be used within VideoProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -7,17 +7,12 @@ import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
const DropdownView = () => {
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
const ControlContext = useControlContext();
|
||||
const [item, mediaSource] = [
|
||||
ControlContext?.item,
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useGestureDetection = ({
|
||||
onVerticalDragEnd,
|
||||
onTap,
|
||||
screenWidth = 400,
|
||||
screenHeight = 800,
|
||||
}: SwipeGestureOptions = {}) => {
|
||||
const touchStartTime = useRef(0);
|
||||
const touchStartPosition = useRef({ x: 0, y: 0 });
|
||||
@@ -36,25 +37,47 @@ export const useGestureDetection = ({
|
||||
const dragSide = useRef<"left" | "right" | null>(null);
|
||||
const hasMovedEnough = useRef(false);
|
||||
const gestureType = useRef<"none" | "horizontal" | "vertical">("none");
|
||||
const shouldIgnoreTouch = useRef(false);
|
||||
|
||||
const handleTouchStart = useCallback((event: GestureResponderEvent) => {
|
||||
touchStartTime.current = Date.now();
|
||||
touchStartPosition.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
lastTouchPosition.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
isDragging.current = false;
|
||||
dragSide.current = null;
|
||||
hasMovedEnough.current = false;
|
||||
gestureType.current = "none";
|
||||
}, []);
|
||||
const handleTouchStart = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const startY = event.nativeEvent.pageY;
|
||||
|
||||
// Define exclusion zones (15% from top and bottom)
|
||||
const topExclusionZone = screenHeight * 0.15;
|
||||
const bottomExclusionZone = screenHeight * 0.85;
|
||||
|
||||
// Check if touch started in exclusion zones
|
||||
if (startY < topExclusionZone || startY > bottomExclusionZone) {
|
||||
shouldIgnoreTouch.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
(event: GestureResponderEvent) => {
|
||||
// Ignore touch if it started in exclusion zone
|
||||
if (shouldIgnoreTouch.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
@@ -106,6 +129,12 @@ export const useGestureDetection = ({
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
// Ignore touch if it started in exclusion zone
|
||||
if (shouldIgnoreTouch.current) {
|
||||
shouldIgnoreTouch.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const touchEndTime = Date.now();
|
||||
const touchEndPosition = {
|
||||
x: event.nativeEvent.pageX,
|
||||
|
||||
@@ -22,7 +22,6 @@ interface UseRemoteControlProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isVlc: boolean;
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
seek: (value: number) => void;
|
||||
@@ -34,11 +33,14 @@ interface UseRemoteControlProps {
|
||||
handleSeekBackward: (seconds: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage TV remote control interactions.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useRemoteControl({
|
||||
progress,
|
||||
min,
|
||||
max,
|
||||
isVlc,
|
||||
showControls,
|
||||
isPlaying,
|
||||
seek,
|
||||
@@ -61,21 +63,18 @@ export function useRemoteControl({
|
||||
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const SCRUB_INTERVAL = isVlc
|
||||
? CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS
|
||||
: CONTROLS_CONSTANTS.SCRUB_INTERVAL_TICKS;
|
||||
// MPV uses ms
|
||||
const SCRUB_INTERVAL = CONTROLS_CONSTANTS.SCRUB_INTERVAL_MS;
|
||||
|
||||
const updateTime = useCallback(
|
||||
(progressValue: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(progressValue) : progressValue;
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
},
|
||||
[isVlc],
|
||||
);
|
||||
const updateTime = useCallback((progressValue: number) => {
|
||||
// Convert ms to ticks for calculation
|
||||
const progressInTicks = msToTicks(progressValue);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, []);
|
||||
|
||||
// TV remote control handling (no-op on non-TV platforms)
|
||||
useTVEventHandler((evt) => {
|
||||
@@ -102,7 +101,8 @@ export function useRemoteControl({
|
||||
Math.min(max.value, base + direction * SCRUB_INTERVAL),
|
||||
);
|
||||
remoteScrubProgress.value = updated;
|
||||
const progressInTicks = isVlc ? msToTicks(updated) : updated;
|
||||
// Convert ms to ticks for trickplay
|
||||
const progressInTicks = msToTicks(updated);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
updateTime(updated);
|
||||
break;
|
||||
@@ -111,9 +111,8 @@ export function useRemoteControl({
|
||||
if (isRemoteScrubbing.value && remoteScrubProgress.value != null) {
|
||||
progress.value = remoteScrubProgress.value;
|
||||
|
||||
const seekTarget = isVlc
|
||||
? Math.max(0, remoteScrubProgress.value)
|
||||
: Math.max(0, ticksToSeconds(remoteScrubProgress.value));
|
||||
// MPV uses ms, seek expects ms
|
||||
const seekTarget = Math.max(0, remoteScrubProgress.value);
|
||||
|
||||
seek(seekTarget);
|
||||
if (isPlaying) play();
|
||||
|
||||
@@ -3,20 +3,22 @@ import type { SharedValue } from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { secondsToMs, ticksToSeconds } from "@/utils/time";
|
||||
import { secondsToMs } from "@/utils/time";
|
||||
|
||||
interface UseVideoNavigationProps {
|
||||
progress: SharedValue<number>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video navigation (seeking forward/backward).
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoNavigation({
|
||||
progress,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
}: UseVideoNavigationProps) {
|
||||
@@ -30,16 +32,15 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(seconds))
|
||||
: Math.max(0, ticksToSeconds(curr) - seconds);
|
||||
// MPV uses ms
|
||||
const newTime = Math.max(0, curr - secondsToMs(seconds));
|
||||
seek(newTime);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
},
|
||||
[isPlaying, isVlc, seek, progress],
|
||||
[isPlaying, seek, progress],
|
||||
);
|
||||
|
||||
const handleSeekForward = useCallback(
|
||||
@@ -48,16 +49,15 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(seconds)
|
||||
: ticksToSeconds(curr) + seconds;
|
||||
// MPV uses ms
|
||||
const newTime = curr + secondsToMs(seconds);
|
||||
seek(Math.max(0, newTime));
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
},
|
||||
[isPlaying, isVlc, seek, progress],
|
||||
[isPlaying, seek, progress],
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
@@ -69,9 +69,11 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||
// MPV uses ms
|
||||
const newTime = Math.max(
|
||||
0,
|
||||
curr - secondsToMs(settings.rewindSkipTime),
|
||||
);
|
||||
seek(newTime);
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
@@ -80,7 +82,7 @@ export function useVideoNavigation({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) {
|
||||
@@ -91,9 +93,8 @@ export function useVideoNavigation({
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = isVlc
|
||||
? curr + secondsToMs(settings.forwardSkipTime)
|
||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||
// MPV uses ms
|
||||
const newTime = curr + secondsToMs(settings.forwardSkipTime);
|
||||
seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
@@ -102,7 +103,7 @@ export function useVideoNavigation({
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying, isVlc, play, seek, progress, lightHapticFeedback]);
|
||||
}, [settings, isPlaying, play, seek, progress, lightHapticFeedback]);
|
||||
|
||||
return {
|
||||
handleSeekBackward,
|
||||
|
||||
@@ -8,7 +8,6 @@ interface UseVideoSliderProps {
|
||||
progress: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isPlaying: boolean;
|
||||
isVlc: boolean;
|
||||
seek: (value: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
@@ -16,11 +15,14 @@ interface UseVideoSliderProps {
|
||||
showControls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video slider interactions.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
isPlaying,
|
||||
isVlc,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
@@ -62,21 +64,20 @@ export function useVideoSlider({
|
||||
setIsSliding(false);
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
const seekValue = Math.max(
|
||||
0,
|
||||
Math.floor(isVlc ? value : ticksToSeconds(value)),
|
||||
);
|
||||
// MPV uses ms, seek expects ms
|
||||
const seekValue = Math.max(0, Math.floor(value));
|
||||
seek(seekValue);
|
||||
if (wasPlayingRef.current) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[isVlc, seek, play, progress, isSeeking],
|
||||
[seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
||||
// Convert ms to ticks for trickplay
|
||||
const progressInTicks = msToTicks(value);
|
||||
calculateTrickplayUrl(progressInTicks);
|
||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
@@ -84,7 +85,7 @@ export function useVideoSlider({
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
||||
[isVlc, calculateTrickplayUrl],
|
||||
[calculateTrickplayUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,21 +4,18 @@ import {
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
} from "react-native-reanimated";
|
||||
import { ticksToSeconds } from "@/utils/time";
|
||||
|
||||
interface UseVideoTimeProps {
|
||||
progress: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
isVlc: boolean;
|
||||
}
|
||||
|
||||
export function useVideoTime({
|
||||
progress,
|
||||
max,
|
||||
isSeeking,
|
||||
isVlc,
|
||||
}: UseVideoTimeProps) {
|
||||
/**
|
||||
* Hook to manage video time display.
|
||||
* MPV player uses milliseconds for time values.
|
||||
*/
|
||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
|
||||
@@ -27,19 +24,16 @@ export function useVideoTime({
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
||||
const remaining = isVlc
|
||||
? maxValue - currentProgress
|
||||
: ticksToSeconds(maxValue - currentProgress);
|
||||
// MPV uses milliseconds
|
||||
const current = currentProgress;
|
||||
const remaining = maxValue - currentProgress;
|
||||
|
||||
// Only update state if the displayed time actually changed (avoid sub-second updates)
|
||||
const currentSeconds = Math.floor(current / (isVlc ? 1000 : 1));
|
||||
const remainingSeconds = Math.floor(remaining / (isVlc ? 1000 : 1));
|
||||
const lastCurrentSeconds = Math.floor(
|
||||
lastCurrentTimeRef.current / (isVlc ? 1000 : 1),
|
||||
);
|
||||
const currentSeconds = Math.floor(current / 1000);
|
||||
const remainingSeconds = Math.floor(remaining / 1000);
|
||||
const lastCurrentSeconds = Math.floor(lastCurrentTimeRef.current / 1000);
|
||||
const lastRemainingSeconds = Math.floor(
|
||||
lastRemainingTimeRef.current / (isVlc ? 1000 : 1),
|
||||
lastRemainingTimeRef.current / 1000,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -52,7 +46,7 @@ export function useVideoTime({
|
||||
lastRemainingTimeRef.current = remaining;
|
||||
}
|
||||
},
|
||||
[isVlc],
|
||||
[],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
playerRef: React.RefObject<VlcPlayerViewRef>;
|
||||
}
|
||||
|
||||
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (playerRef.current) {
|
||||
const audio = await playerRef.current.getAudioTracks();
|
||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
||||
setAudioTracks(audio);
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [playerRef]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
left: insets.left + 8,
|
||||
zIndex: 100,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Text className='font-bold'>{t("player.playback_state")}</Text>
|
||||
<Text className='font-bold mt-2.5'>{t("player.audio_tracks")}</Text>
|
||||
{audioTracks?.map((track, index) => (
|
||||
<Text key={index}>
|
||||
{track.name} ({t("player.index")} {track.index})
|
||||
</Text>
|
||||
))}
|
||||
<Text className='font-bold mt-2.5'>{t("player.subtitles_tracks")}</Text>
|
||||
{subtitleTracks?.map((track, index) => (
|
||||
<Text key={index}>
|
||||
{track.name} ({t("player.index")} {track.index})
|
||||
</Text>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className='mt-2.5 bg-blue-500 p-2 rounded'
|
||||
onPress={() => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className='text-white text-center'>
|
||||
{t("player.refresh_tracks")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
export type VLCColor =
|
||||
| "Black"
|
||||
| "Gray"
|
||||
| "Silver"
|
||||
| "White"
|
||||
| "Maroon"
|
||||
| "Red"
|
||||
| "Fuchsia"
|
||||
| "Yellow"
|
||||
| "Olive"
|
||||
| "Green"
|
||||
| "Teal"
|
||||
| "Lime"
|
||||
| "Purple"
|
||||
| "Navy"
|
||||
| "Blue"
|
||||
| "Aqua";
|
||||
|
||||
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
|
||||
|
||||
export const VLC_COLORS: Record<VLCColor, number> = {
|
||||
Black: 0,
|
||||
Gray: 8421504,
|
||||
Silver: 12632256,
|
||||
White: 16777215,
|
||||
Maroon: 8388608,
|
||||
Red: 16711680,
|
||||
Fuchsia: 16711935,
|
||||
Yellow: 16776960,
|
||||
Olive: 8421376,
|
||||
Green: 32768,
|
||||
Teal: 32896,
|
||||
Lime: 65280,
|
||||
Purple: 8388736,
|
||||
Navy: 128,
|
||||
Blue: 255,
|
||||
Aqua: 65535,
|
||||
};
|
||||
|
||||
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
|
||||
None: 0,
|
||||
Thin: 2,
|
||||
Normal: 4,
|
||||
Thick: 6,
|
||||
};
|
||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.47.1",
|
||||
"channel": "0.48.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.47.1",
|
||||
"channel": "0.48.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.47.1",
|
||||
"channel": "0.48.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -5,12 +5,15 @@ import { useSegments } from "@/utils/segments";
|
||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||
import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping credits in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*/
|
||||
export const useCreditSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (time: number) => void,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
@@ -18,16 +21,11 @@ export const useCreditSkipper = (
|
||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
}
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
if (isVlc) {
|
||||
seek(secondsToMs(seconds));
|
||||
return;
|
||||
}
|
||||
seek(seconds);
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
@@ -41,11 +39,11 @@ export const useCreditSkipper = (
|
||||
useEffect(() => {
|
||||
if (creditTimestamps) {
|
||||
setShowSkipCreditButton(
|
||||
currentTime > creditTimestamps.startTime &&
|
||||
currentTime < creditTimestamps.endTime,
|
||||
currentTimeSeconds > creditTimestamps.startTime &&
|
||||
currentTimeSeconds < creditTimestamps.endTime,
|
||||
);
|
||||
}
|
||||
}, [creditTimestamps, currentTime]);
|
||||
}, [creditTimestamps, currentTimeSeconds]);
|
||||
|
||||
const skipCredit = useCallback(() => {
|
||||
if (!creditTimestamps) return;
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
// Used only for initial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto,
|
||||
settings: Settings | null,
|
||||
) => {
|
||||
const playSettings = useMemo(() => {
|
||||
// 1. Get first media source
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) =>
|
||||
x.Type === "Audio" &&
|
||||
x.Language ===
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
|
||||
)?.Index;
|
||||
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio",
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
let bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
// value undefined seems to get lost in settings. This is just a failsafe
|
||||
if (bitrate.key === BITRATES[0].key) {
|
||||
bitrate = BITRATES[0];
|
||||
}
|
||||
/**
|
||||
* React hook wrapper for getDefaultPlaySettings.
|
||||
* Used in UI components for initial playback (no previous track state).
|
||||
*/
|
||||
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||
useMemo(() => {
|
||||
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex ?? defaultAudioIndex ?? firstAudioIndex ?? undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
defaultMediaSource: mediaSource ?? undefined,
|
||||
defaultBitrate: bitrate ?? undefined,
|
||||
defaultMediaSource: mediaSource,
|
||||
defaultAudioIndex: audioIndex,
|
||||
defaultSubtitleIndex: subtitleIndex,
|
||||
defaultBitrate: bitrate,
|
||||
};
|
||||
}, [
|
||||
item.MediaSources,
|
||||
settings?.defaultAudioLanguage,
|
||||
settings?.defaultSubtitleLanguage,
|
||||
]);
|
||||
|
||||
return playSettings;
|
||||
};
|
||||
}, [item, settings]);
|
||||
|
||||
export default useDefaultPlaySettings;
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
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";
|
||||
|
||||
// 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 {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
@@ -64,11 +70,13 @@ export const useImageColors = ({
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
getColors(source.uri, {
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsResult) => {
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
||||
// 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 {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
@@ -80,11 +86,13 @@ export const useImageColorsReturn = ({
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
getColors(source.uri, {
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsResult) => {
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
@@ -7,31 +7,26 @@ import { useHaptic } from "./useHaptic";
|
||||
|
||||
/**
|
||||
* Custom hook to handle skipping intros in a media player.
|
||||
* MPV player uses milliseconds for time.
|
||||
*
|
||||
* @param {number} currentTime - The current playback time in seconds.
|
||||
* @param {number} currentTime - The current playback time in milliseconds.
|
||||
*/
|
||||
export const useIntroSkipper = (
|
||||
itemId: string,
|
||||
currentTime: number,
|
||||
seek: (ticks: number) => void,
|
||||
seek: (ms: number) => void,
|
||||
play: () => void,
|
||||
isVlc = false,
|
||||
isOffline = false,
|
||||
api: Api | null = null,
|
||||
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||
) => {
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
if (isVlc) {
|
||||
currentTime = msToSeconds(currentTime);
|
||||
}
|
||||
// Convert ms to seconds for comparison with timestamps
|
||||
const currentTimeSeconds = msToSeconds(currentTime);
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const wrappedSeek = (seconds: number) => {
|
||||
if (isVlc) {
|
||||
seek(secondsToMs(seconds));
|
||||
return;
|
||||
}
|
||||
seek(seconds);
|
||||
seek(secondsToMs(seconds));
|
||||
};
|
||||
|
||||
const { data: segments } = useSegments(
|
||||
@@ -43,49 +38,22 @@ export const useIntroSkipper = (
|
||||
const introTimestamps = segments?.introSegments?.[0];
|
||||
|
||||
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) {
|
||||
const shouldShow =
|
||||
currentTime > introTimestamps.startTime &&
|
||||
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,
|
||||
});
|
||||
currentTimeSeconds > introTimestamps.startTime &&
|
||||
currentTimeSeconds < introTimestamps.endTime;
|
||||
|
||||
setShowSkipButton(shouldShow);
|
||||
} else {
|
||||
if (showSkipButton) {
|
||||
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
|
||||
setShowSkipButton(false);
|
||||
}
|
||||
}
|
||||
}, [introTimestamps, currentTime, showSkipButton]);
|
||||
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||
|
||||
const skipIntro = useCallback(() => {
|
||||
if (!introTimestamps) return;
|
||||
try {
|
||||
console.log(
|
||||
`[INTRO_SKIPPER] Skipping intro to:`,
|
||||
introTimestamps.endTime,
|
||||
);
|
||||
lightHapticFeedback();
|
||||
wrappedSeek(introTimestamps.endTime);
|
||||
setTimeout(() => {
|
||||
@@ -96,7 +64,5 @@ export const useIntroSkipper = (
|
||||
}
|
||||
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||
|
||||
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
|
||||
|
||||
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 { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
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 [user] = useAtom(userAtom);
|
||||
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({
|
||||
queryKey: ["item", itemId],
|
||||
queryKey: ["item", itemId, finalFields],
|
||||
queryFn: async () => {
|
||||
if (!itemId) throw new Error("Item ID is required");
|
||||
|
||||
if (isOffline) {
|
||||
return getDownloadedItemById(itemId)?.item;
|
||||
}
|
||||
if (!api || !user || !itemId) return null;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: itemId,
|
||||
userId: user?.Id,
|
||||
|
||||
if (!api || !user) return null;
|
||||
|
||||
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,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
|
||||
@@ -244,6 +244,22 @@ export class JellyseerrApi {
|
||||
.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(
|
||||
params = {
|
||||
filter: "all",
|
||||
@@ -512,7 +528,7 @@ export const useJellyseerr = () => {
|
||||
};
|
||||
|
||||
const jellyseerrRegion = useMemo(
|
||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { OrientationChangeEvent } from "expo-screen-orientation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import {
|
||||
addOrientationChangeListener,
|
||||
getOrientationAsync,
|
||||
lockAsync,
|
||||
Orientation as OrientationEnum,
|
||||
OrientationLock,
|
||||
unlockAsync,
|
||||
} from "@/packages/expo-screen-orientation";
|
||||
import { Orientation } from "../packages/expo-screen-orientation.tv";
|
||||
|
||||
const orientationToOrientationLock = (
|
||||
orientation: Orientation,
|
||||
): OrientationLock => {
|
||||
orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum],
|
||||
): (typeof OrientationLock)[keyof typeof OrientationLock] => {
|
||||
switch (orientation) {
|
||||
case Orientation.LANDSCAPE_LEFT:
|
||||
return OrientationLock.LANDSCAPE_LEFT;
|
||||
@@ -21,44 +28,53 @@ const orientationToOrientationLock = (
|
||||
|
||||
export const useOrientation = () => {
|
||||
const [orientation, setOrientation] = useState(
|
||||
Platform.isTV
|
||||
? ScreenOrientation.OrientationLock.LANDSCAPE
|
||||
: ScreenOrientation.OrientationLock.UNKNOWN,
|
||||
Platform.isTV ? OrientationLock.LANDSCAPE : OrientationLock.UNKNOWN,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
const orientationSubscription =
|
||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||
const orientationSubscription = addOrientationChangeListener(
|
||||
(event: OrientationChangeEvent) => {
|
||||
setOrientation(
|
||||
orientationToOrientationLock(event.orientationInfo.orientation),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
});
|
||||
getOrientationAsync().then(
|
||||
(orientation: (typeof OrientationEnum)[keyof typeof OrientationEnum]) => {
|
||||
setOrientation(orientationToOrientationLock(orientation));
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
orientationSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lockOrientation = async (lock: OrientationLock) => {
|
||||
const lockOrientation = useCallback(
|
||||
async (lock: (typeof OrientationLock)[keyof typeof OrientationLock]) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (lock === OrientationLock.DEFAULT) {
|
||||
await unlockAsync();
|
||||
} else {
|
||||
await lockAsync(lock);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unlockOrientation = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
await unlockAsync();
|
||||
}, []);
|
||||
|
||||
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
|
||||
await ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
await ScreenOrientation.lockAsync(lock);
|
||||
}
|
||||
return {
|
||||
orientation,
|
||||
setOrientation,
|
||||
lockOrientation,
|
||||
unlockOrientation,
|
||||
};
|
||||
|
||||
const unlockOrientation = async () => {
|
||||
if (Platform.isTV) return;
|
||||
await ScreenOrientation.unlockAsync();
|
||||
};
|
||||
|
||||
return { orientation, setOrientation, lockOrientation, unlockOrientation };
|
||||
};
|
||||
|
||||
@@ -96,8 +96,6 @@ export const useWebSocket = ({
|
||||
| Record<string, string>
|
||||
| undefined; // Arguments are Dictionary<string, string>
|
||||
|
||||
console.log("[WS] ~ ", lastMessage);
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ViewStyle } from "react-native";
|
||||
|
||||
export type PlaybackStatePayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isBuffering: boolean;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProgressUpdatePayload = {
|
||||
nativeEvent: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoLoadStartPayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PipStartedPayload = {
|
||||
nativeEvent: {
|
||||
pipStarted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||
|
||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||
|
||||
export type VlcPlayerSource = {
|
||||
uri: string;
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
startPosition?: number;
|
||||
externalSubtitles?: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
};
|
||||
|
||||
export type TrackInfo = {
|
||||
name: string;
|
||||
index: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type ChapterInfo = {
|
||||
name: string;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type VlcPlayerViewProps = {
|
||||
source: VlcPlayerSource;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
progressUpdateInterval?: number;
|
||||
paused?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
videoAspectRatio?: string;
|
||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||
onPipStarted?: (event: PipStartedPayload) => void;
|
||||
};
|
||||
|
||||
export interface VlcPlayerViewRef {
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seekTo: (time: number) => Promise<void>;
|
||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
||||
setAudioDelay: (delay: number) => Promise<void>;
|
||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
||||
setRate: (rate: number) => Promise<void>;
|
||||
nextChapter: () => Promise<void>;
|
||||
previousChapter: () => Promise<void>;
|
||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||
setVideoCropGeometry: (cropGeometry: string | null) => Promise<void>;
|
||||
getVideoCropGeometry: () => Promise<string | null>;
|
||||
setSubtitleURL: (url: string) => Promise<void>;
|
||||
setVideoAspectRatio: (aspectRatio: string | null) => Promise<void>;
|
||||
setVideoScaleFactor: (scaleFactor: number) => Promise<void>;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import * as React from "react";
|
||||
import { ViewStyle } from "react-native";
|
||||
import type {
|
||||
VlcPlayerSource,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
|
||||
interface NativeViewRef extends VlcPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const VLCViewManager = requireNativeViewManager("VlcPlayer");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
return <VLCViewManager {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
stop: async () => {
|
||||
await nativeRef.current?.stop();
|
||||
},
|
||||
seekTo: async (time: number) => {
|
||||
await nativeRef.current?.seekTo(time);
|
||||
},
|
||||
setAudioTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getAudioTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setSubtitleDelay(delay);
|
||||
},
|
||||
setAudioDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setAudioDelay(delay);
|
||||
},
|
||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
||||
},
|
||||
setRate: async (rate: number) => {
|
||||
await nativeRef.current?.setRate(rate);
|
||||
},
|
||||
nextChapter: async () => {
|
||||
await nativeRef.current?.nextChapter();
|
||||
},
|
||||
previousChapter: async () => {
|
||||
await nativeRef.current?.previousChapter();
|
||||
},
|
||||
getChapters: async () => {
|
||||
const chapters = await nativeRef.current?.getChapters();
|
||||
return chapters ?? null;
|
||||
},
|
||||
setVideoCropGeometry: async (geometry: string | null) => {
|
||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
||||
},
|
||||
getVideoCropGeometry: async () => {
|
||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||
return geometry ?? null;
|
||||
},
|
||||
setSubtitleURL: async (url: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url);
|
||||
},
|
||||
setVideoAspectRatio: async (aspectRatio: string | null) => {
|
||||
await nativeRef.current?.setVideoAspectRatio(aspectRatio);
|
||||
},
|
||||
setVideoScaleFactor: async (scaleFactor: number) => {
|
||||
await nativeRef.current?.setVideoScaleFactor(scaleFactor);
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
source,
|
||||
style,
|
||||
progressUpdateInterval = 500,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
videoAspectRatio,
|
||||
onVideoLoadStart,
|
||||
onVideoStateChange,
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const processedSource: VlcPlayerSource =
|
||||
typeof source === "string"
|
||||
? ({ uri: source } as unknown as VlcPlayerSource)
|
||||
: source;
|
||||
|
||||
if (processedSource.startPosition !== undefined) {
|
||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...otherProps}
|
||||
ref={nativeRef}
|
||||
source={processedSource}
|
||||
style={[{ width: "100%", height: "100%" }, style as ViewStyle]}
|
||||
progressUpdateInterval={progressUpdateInterval}
|
||||
paused={paused}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
onVideoLoadStart={onVideoLoadStart}
|
||||
onVideoLoadEnd={onVideoLoadEnd}
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default VlcPlayerView;
|
||||
@@ -35,7 +35,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
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 {
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import type {
|
||||
ChapterInfo,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
TrackInfo,
|
||||
VideoLoadStartPayload,
|
||||
VideoProgressPayload,
|
||||
VideoStateChangePayload,
|
||||
VlcPlayerSource,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
// Background Downloader
|
||||
export type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
@@ -19,23 +6,21 @@ export type {
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
// Background Downloader
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
// Component
|
||||
export { VlcPlayerView };
|
||||
|
||||
// Component Types
|
||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
||||
|
||||
// Media Types
|
||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
||||
|
||||
// Playback Events (alphabetically sorted)
|
||||
// Type aliases for backward compatibility during migration
|
||||
// These map old VLC type names to new MPV equivalents
|
||||
export type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoProgressPayload,
|
||||
VideoStateChangePayload,
|
||||
};
|
||||
AudioTrack,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
OnErrorEventPayload,
|
||||
OnLoadEventPayload,
|
||||
OnPlaybackStateChangePayload,
|
||||
OnProgressEventPayload,
|
||||
OnTracksReadyEventPayload,
|
||||
SubtitleTrack,
|
||||
SubtitleTrack as TrackInfo,
|
||||
VideoSource,
|
||||
} from "./mpv-player";
|
||||
// MPV Player - Main exports
|
||||
export { MpvPlayerView } from "./mpv-player";
|
||||
|
||||
43
modules/mpv-player/android/build.gradle
Normal file
43
modules/mpv-player/android/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.mpvplayer'
|
||||
version = '0.7.6'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 24)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.mpvplayer"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.7.6"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.net.URL
|
||||
|
||||
class MpvPlayerModule : Module() {
|
||||
// Each module class must implement the definition function. The definition consists of components
|
||||
// that describes the module's functionality and behavior.
|
||||
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
||||
override fun definition() = ModuleDefinition {
|
||||
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
||||
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
||||
// The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript.
|
||||
Name("MpvPlayer")
|
||||
|
||||
// Defines constant property on the module.
|
||||
Constant("PI") {
|
||||
Math.PI
|
||||
}
|
||||
|
||||
// Defines event names that the module can send to JavaScript.
|
||||
Events("onChange")
|
||||
|
||||
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
||||
Function("hello") {
|
||||
"Hello world! 👋"
|
||||
}
|
||||
|
||||
// Defines a JavaScript function that always returns a Promise and whose native code
|
||||
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
||||
AsyncFunction("setValueAsync") { value: String ->
|
||||
// Send an event to JavaScript.
|
||||
sendEvent("onChange", mapOf(
|
||||
"value" to value
|
||||
))
|
||||
}
|
||||
|
||||
// Enables the module to be used as a native view. Definition components that are accepted as part of
|
||||
// the view definition: Prop, Events.
|
||||
View(MpvPlayerView::class) {
|
||||
// Defines a setter for the `url` prop.
|
||||
Prop("url") { view: MpvPlayerView, url: URL ->
|
||||
view.webView.loadUrl(url.toString())
|
||||
}
|
||||
// Defines an event that the view can send to JavaScript.
|
||||
Events("onLoad")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
// Creates and initializes an event dispatcher for the `onLoad` event.
|
||||
// The name of the event is inferred from the value and needs to match the event name defined in the module.
|
||||
private val onLoad by EventDispatcher()
|
||||
|
||||
// Defines a WebView that will be used as the root subview.
|
||||
internal val webView = WebView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
|
||||
onLoad(mapOf("url" to url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Adds the WebView to the view hierarchy.
|
||||
addView(webView)
|
||||
}
|
||||
}
|
||||
6
modules/mpv-player/expo-module.config.json
Normal file
6
modules/mpv-player/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["MpvPlayerModule"]
|
||||
}
|
||||
}
|
||||
2
modules/mpv-player/index.ts
Normal file
2
modules/mpv-player/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./src/MpvPlayer.types";
|
||||
export { default as MpvPlayerView } from "./src/MpvPlayerView";
|
||||
245
modules/mpv-player/ios/IOSurfaceBufferPool.swift
Normal file
245
modules/mpv-player/ios/IOSurfaceBufferPool.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
import CoreVideo
|
||||
import Metal
|
||||
import CoreMedia
|
||||
import AVFoundation
|
||||
|
||||
/// Manages a pool of IOSurface-backed CVPixelBuffers that can be shared between Metal and AVFoundation
|
||||
/// This enables zero-copy rendering where mpv renders to Metal textures that are directly usable by AVSampleBufferDisplayLayer
|
||||
final class IOSurfaceBufferPool {
|
||||
|
||||
struct PooledBuffer {
|
||||
let pixelBuffer: CVPixelBuffer
|
||||
let texture: MTLTexture
|
||||
let ioSurface: IOSurfaceRef
|
||||
}
|
||||
|
||||
private let device: MTLDevice
|
||||
private var pool: CVPixelBufferPool?
|
||||
private var buffers: [PooledBuffer] = []
|
||||
private var availableBuffers: [PooledBuffer] = []
|
||||
private let lock = NSLock()
|
||||
|
||||
private(set) var width: Int = 0
|
||||
private(set) var height: Int = 0
|
||||
private(set) var pixelFormat: OSType = kCVPixelFormatType_32BGRA
|
||||
|
||||
private let maxBufferCount: Int
|
||||
|
||||
init(device: MTLDevice, maxBufferCount: Int = 3) {
|
||||
self.device = device
|
||||
self.maxBufferCount = maxBufferCount
|
||||
}
|
||||
|
||||
deinit {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/// Configure the pool for a specific video size and format
|
||||
func configure(width: Int, height: Int, pixelFormat: OSType = kCVPixelFormatType_32BGRA) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard width > 0, height > 0 else { return false }
|
||||
|
||||
// Skip if already configured for this size
|
||||
if self.width == width && self.height == height && self.pixelFormat == pixelFormat && pool != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear existing buffers
|
||||
buffers.removeAll()
|
||||
availableBuffers.removeAll()
|
||||
pool = nil
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.pixelFormat = pixelFormat
|
||||
|
||||
// Create pixel buffer pool with IOSurface and Metal compatibility
|
||||
let pixelBufferAttributes: [CFString: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey: pixelFormat,
|
||||
kCVPixelBufferWidthKey: width,
|
||||
kCVPixelBufferHeightKey: height,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
|
||||
kCVPixelBufferMetalCompatibilityKey: true,
|
||||
kCVPixelBufferCGImageCompatibilityKey: true,
|
||||
kCVPixelBufferCGBitmapContextCompatibilityKey: true
|
||||
]
|
||||
|
||||
let poolAttributes: [CFString: Any] = [
|
||||
kCVPixelBufferPoolMinimumBufferCountKey: maxBufferCount
|
||||
]
|
||||
|
||||
var newPool: CVPixelBufferPool?
|
||||
let status = CVPixelBufferPoolCreate(
|
||||
kCFAllocatorDefault,
|
||||
poolAttributes as CFDictionary,
|
||||
pixelBufferAttributes as CFDictionary,
|
||||
&newPool
|
||||
)
|
||||
|
||||
guard status == kCVReturnSuccess, let createdPool = newPool else {
|
||||
Logger.shared.log("Failed to create IOSurface buffer pool: \(status)", type: "Error")
|
||||
return false
|
||||
}
|
||||
|
||||
pool = createdPool
|
||||
|
||||
// Pre-allocate buffers
|
||||
for _ in 0..<maxBufferCount {
|
||||
if let buffer = createPooledBuffer() {
|
||||
buffers.append(buffer)
|
||||
availableBuffers.append(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Get an available buffer for rendering
|
||||
func dequeueBuffer() -> PooledBuffer? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if let buffer = availableBuffers.popLast() {
|
||||
return buffer
|
||||
}
|
||||
|
||||
// Try to create a new buffer if under limit
|
||||
if buffers.count < maxBufferCount, let buffer = createPooledBuffer() {
|
||||
buffers.append(buffer)
|
||||
return buffer
|
||||
}
|
||||
|
||||
// All buffers in use - create temporary one
|
||||
return createPooledBuffer()
|
||||
}
|
||||
|
||||
/// Return a buffer to the pool after use
|
||||
func enqueueBuffer(_ buffer: PooledBuffer) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if buffers.contains(where: { $0.pixelBuffer == buffer.pixelBuffer }) {
|
||||
availableBuffers.append(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all buffers and reset the pool
|
||||
func invalidate() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
buffers.removeAll()
|
||||
availableBuffers.removeAll()
|
||||
pool = nil
|
||||
width = 0
|
||||
height = 0
|
||||
}
|
||||
|
||||
private func createPooledBuffer() -> PooledBuffer? {
|
||||
guard let pool = pool else { return nil }
|
||||
|
||||
var pixelBuffer: CVPixelBuffer?
|
||||
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer)
|
||||
|
||||
guard status == kCVReturnSuccess, let buffer = pixelBuffer else {
|
||||
Logger.shared.log("Failed to create pixel buffer from pool: \(status)", type: "Error")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get IOSurface from pixel buffer
|
||||
guard let ioSurface = CVPixelBufferGetIOSurface(buffer)?.takeUnretainedValue() else {
|
||||
Logger.shared.log("Failed to get IOSurface from pixel buffer", type: "Error")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create Metal texture from IOSurface
|
||||
let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: metalPixelFormat(for: pixelFormat),
|
||||
width: width,
|
||||
height: height,
|
||||
mipmapped: false
|
||||
)
|
||||
textureDescriptor.usage = [.renderTarget, .shaderRead, .shaderWrite]
|
||||
textureDescriptor.storageMode = .shared
|
||||
|
||||
guard let texture = device.makeTexture(descriptor: textureDescriptor, iosurface: ioSurface, plane: 0) else {
|
||||
Logger.shared.log("Failed to create Metal texture from IOSurface", type: "Error")
|
||||
return nil
|
||||
}
|
||||
|
||||
return PooledBuffer(pixelBuffer: buffer, texture: texture, ioSurface: ioSurface)
|
||||
}
|
||||
|
||||
private func metalPixelFormat(for cvFormat: OSType) -> MTLPixelFormat {
|
||||
switch cvFormat {
|
||||
case kCVPixelFormatType_32BGRA:
|
||||
return .bgra8Unorm
|
||||
case kCVPixelFormatType_32RGBA:
|
||||
return .rgba8Unorm
|
||||
case kCVPixelFormatType_64RGBAHalf:
|
||||
return .rgba16Float
|
||||
default:
|
||||
return .bgra8Unorm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CMSampleBuffer Creation
|
||||
|
||||
extension IOSurfaceBufferPool {
|
||||
|
||||
/// Create a CMSampleBuffer from a pooled buffer for AVSampleBufferDisplayLayer
|
||||
static func createSampleBuffer(
|
||||
from pixelBuffer: CVPixelBuffer,
|
||||
formatDescription: CMVideoFormatDescription,
|
||||
presentationTime: CMTime
|
||||
) -> CMSampleBuffer? {
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: .invalid,
|
||||
presentationTimeStamp: presentationTime,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
let status = CMSampleBufferCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
dataReady: true,
|
||||
makeDataReadyCallback: nil,
|
||||
refcon: nil,
|
||||
formatDescription: formatDescription,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
Logger.shared.log("Failed to create sample buffer: \(status)", type: "Error")
|
||||
return nil
|
||||
}
|
||||
|
||||
return sampleBuffer
|
||||
}
|
||||
|
||||
/// Create a format description for the current pool configuration
|
||||
func createFormatDescription() -> CMVideoFormatDescription? {
|
||||
guard let buffer = dequeueBuffer() else { return nil }
|
||||
defer { enqueueBuffer(buffer) }
|
||||
|
||||
var formatDescription: CMVideoFormatDescription?
|
||||
let status = CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: buffer.pixelBuffer,
|
||||
formatDescriptionOut: &formatDescription
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
Logger.shared.log("Failed to create format description: \(status)", type: "Error")
|
||||
return nil
|
||||
}
|
||||
|
||||
return formatDescription
|
||||
}
|
||||
}
|
||||
162
modules/mpv-player/ios/Logger.swift
Normal file
162
modules/mpv-player/ios/Logger.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
|
||||
final class Logger {
|
||||
static let shared = Logger()
|
||||
|
||||
struct LogEntry {
|
||||
let message: String
|
||||
let type: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "mpvkit.logger", attributes: .concurrent)
|
||||
private var logs: [LogEntry] = []
|
||||
private let logFileURL: URL
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
private let maxFileSize = 1024 * 512
|
||||
private let maxLogEntries = 1000
|
||||
|
||||
private init() {
|
||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
logFileURL = tmpDir.appendingPathComponent("logs.txt")
|
||||
|
||||
dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
}
|
||||
|
||||
func log(_ message: String, type: String = "General") {
|
||||
let entry = LogEntry(message: message, type: type, timestamp: Date())
|
||||
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
self.logs.append(entry)
|
||||
|
||||
if self.logs.count > self.maxLogEntries {
|
||||
self.logs.removeFirst(self.logs.count - self.maxLogEntries)
|
||||
}
|
||||
|
||||
self.saveLogToFile(entry)
|
||||
|
||||
#if DEBUG
|
||||
self.debugLog(entry)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("LoggerNotification"),
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"message": message,
|
||||
"type": type,
|
||||
"timestamp": entry.timestamp
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLogs() -> String {
|
||||
var result = ""
|
||||
queue.sync {
|
||||
result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||
.joined(separator: "\n----\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getLogsAsync() async -> String {
|
||||
return await withCheckedContinuation { continuation in
|
||||
queue.async { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume(returning: "")
|
||||
return
|
||||
}
|
||||
let result = self.logs.map { "[\(self.dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
|
||||
.joined(separator: "\n----\n")
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogs() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logs.removeAll()
|
||||
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
func clearLogsAsync() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.logs.removeAll()
|
||||
try? FileManager.default.removeItem(at: self.logFileURL)
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveLogToFile(_ log: LogEntry) {
|
||||
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
|
||||
|
||||
guard let data = logString.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: logFileURL.path) {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path)
|
||||
let fileSize = attributes[.size] as? UInt64 ?? 0
|
||||
|
||||
if fileSize + UInt64(data.count) > maxFileSize {
|
||||
self.truncateLogFile()
|
||||
}
|
||||
|
||||
if let handle = try? FileHandle(forWritingTo: logFileURL) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
handle.closeFile()
|
||||
}
|
||||
} else {
|
||||
try data.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
try? data.write(to: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func truncateLogFile() {
|
||||
do {
|
||||
guard let content = try? String(contentsOf: logFileURL, encoding: .utf8),
|
||||
!content.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let entries = content.components(separatedBy: "\n---\n")
|
||||
guard entries.count > 10 else { return }
|
||||
|
||||
let keepCount = entries.count / 2
|
||||
let truncatedEntries = Array(entries.suffix(keepCount))
|
||||
let truncatedContent = truncatedEntries.joined(separator: "\n---\n")
|
||||
|
||||
if let truncatedData = truncatedContent.data(using: .utf8) {
|
||||
try truncatedData.write(to: logFileURL)
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: logFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func debugLog(_ entry: LogEntry) {
|
||||
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
|
||||
NSLog("%@", formattedMessage)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
1107
modules/mpv-player/ios/MPVMetalRenderer.swift
Normal file
1107
modules/mpv-player/ios/MPVMetalRenderer.swift
Normal file
File diff suppressed because it is too large
Load Diff
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
28
modules/mpv-player/ios/MpvPlayer.podspec
Normal file
@@ -0,0 +1,28 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPVKit for Expo'
|
||||
s.description = 'MPVKit for Expo'
|
||||
s.author = 'mpvkit'
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit', '~> 0.40.0'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
# Strip debug symbols to avoid DWARF errors from MPVKit
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
145
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
145
modules/mpv-player/ios/MpvPlayerModule.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class MpvPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("MpvPlayer")
|
||||
|
||||
// Enables the module to be used as a native view
|
||||
View(MpvPlayerView.self) {
|
||||
// All video load options are passed via a single "source" prop
|
||||
Prop("source") { (view: MpvPlayerView, source: [String: Any]?) in
|
||||
guard let source = source,
|
||||
let urlString = source["url"] as? String,
|
||||
let videoURL = URL(string: urlString) else { return }
|
||||
|
||||
let config = VideoLoadConfig(
|
||||
url: videoURL,
|
||||
headers: source["headers"] as? [String: String],
|
||||
externalSubtitles: source["externalSubtitles"] as? [String],
|
||||
startPosition: source["startPosition"] as? Double,
|
||||
autoplay: (source["autoplay"] as? Bool) ?? true,
|
||||
initialSubtitleId: source["initialSubtitleId"] as? Int,
|
||||
initialAudioId: source["initialAudioId"] as? Int
|
||||
)
|
||||
|
||||
view.loadVideo(config: config)
|
||||
}
|
||||
|
||||
// Playback controls
|
||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
}
|
||||
|
||||
AsyncFunction("seekBy") { (view: MpvPlayerView, offset: Double) in
|
||||
view.seekBy(offset: offset)
|
||||
}
|
||||
|
||||
AsyncFunction("setSpeed") { (view: MpvPlayerView, speed: Double) in
|
||||
view.setSpeed(speed: speed)
|
||||
}
|
||||
|
||||
AsyncFunction("getSpeed") { (view: MpvPlayerView) -> Double in
|
||||
return view.getSpeed()
|
||||
}
|
||||
|
||||
AsyncFunction("isPaused") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPaused()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentPosition") { (view: MpvPlayerView) -> Double in
|
||||
return view.getCurrentPosition()
|
||||
}
|
||||
|
||||
AsyncFunction("getDuration") { (view: MpvPlayerView) -> Double in
|
||||
return view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture
|
||||
AsyncFunction("startPictureInPicture") { (view: MpvPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { (view: MpvPlayerView) in
|
||||
view.stopPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { (view: MpvPlayerView) -> Bool in
|
||||
return view.isPictureInPictureActive()
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { (view: MpvPlayerView) in
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { (view: MpvPlayerView) -> Int in
|
||||
return view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { (view: MpvPlayerView, url: String, select: Bool) in
|
||||
view.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// Subtitle positioning
|
||||
AsyncFunction("setSubtitlePosition") { (view: MpvPlayerView, position: Int) in
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { (view: MpvPlayerView, scale: Double) in
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { (view: MpvPlayerView, margin: Int) in
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { (view: MpvPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { (view: MpvPlayerView, alignment: String) in
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { (view: MpvPlayerView, size: Int) in
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { (view: MpvPlayerView) -> [[String: Any]] in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: MpvPlayerView, trackId: Int) in
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { (view: MpvPlayerView) -> Int in
|
||||
return view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
}
|
||||
}
|
||||
380
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
380
modules/mpv-player/ios/MpvPlayerView.swift
Normal file
@@ -0,0 +1,380 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
/// Configuration for loading a video
|
||||
struct VideoLoadConfig {
|
||||
let url: URL
|
||||
var headers: [String: String]?
|
||||
var externalSubtitles: [String]?
|
||||
var startPosition: Double?
|
||||
var autoplay: Bool
|
||||
/// MPV subtitle track ID to select on start (1-based, -1 to disable, nil to use default)
|
||||
var initialSubtitleId: Int?
|
||||
/// MPV audio track ID to select on start (1-based, nil to use default)
|
||||
var initialAudioId: Int?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
headers: [String: String]? = nil,
|
||||
externalSubtitles: [String]? = nil,
|
||||
startPosition: Double? = nil,
|
||||
autoplay: Bool = true,
|
||||
initialSubtitleId: Int? = nil,
|
||||
initialAudioId: Int? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.externalSubtitles = externalSubtitles
|
||||
self.startPosition = startPosition
|
||||
self.autoplay = autoplay
|
||||
self.initialSubtitleId = initialSubtitleId
|
||||
self.initialAudioId = initialAudioId
|
||||
}
|
||||
}
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class MpvPlayerView: ExpoView {
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var renderer: MPVMetalRenderer?
|
||||
private var videoContainer: UIView!
|
||||
private var pipController: PiPController?
|
||||
|
||||
let onLoad = EventDispatcher()
|
||||
let onPlaybackStateChange = EventDispatcher()
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
private var cachedDuration: Double = 0
|
||||
private var intendedPlayState: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
videoContainer.backgroundColor = .black
|
||||
videoContainer.clipsToBounds = true
|
||||
addSubview(videoContainer)
|
||||
|
||||
displayLayer.frame = bounds
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
if #available(iOS 17.0, *) {
|
||||
displayLayer.wantsExtendedDynamicRangeContent = true
|
||||
}
|
||||
displayLayer.backgroundColor = UIColor.black.cgColor
|
||||
videoContainer.layer.addSublayer(displayLayer)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
videoContainer.topAnchor.constraint(equalTo: topAnchor),
|
||||
videoContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
videoContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
videoContainer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
do {
|
||||
renderer = try MPVMetalRenderer(displayLayer: displayLayer)
|
||||
renderer?.delegate = self
|
||||
try renderer?.start()
|
||||
} catch MPVMetalRenderer.RendererError.metalNotSupported {
|
||||
onError(["error": "Metal is not supported on this device"])
|
||||
} catch {
|
||||
onError(["error": "Failed to start renderer: \(error.localizedDescription)"])
|
||||
}
|
||||
|
||||
// Setup PiP
|
||||
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
|
||||
pipController?.delegate = self
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
displayLayer.frame = videoContainer.bounds
|
||||
displayLayer.isHidden = false
|
||||
displayLayer.opacity = 1.0
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func loadVideo(config: VideoLoadConfig) {
|
||||
// Skip reload if same URL is already playing
|
||||
if currentURL == config.url {
|
||||
return
|
||||
}
|
||||
currentURL = config.url
|
||||
|
||||
let preset = PlayerPreset(
|
||||
id: .sdrRec709,
|
||||
title: "Default",
|
||||
summary: "Default playback preset",
|
||||
stream: nil,
|
||||
commands: []
|
||||
)
|
||||
|
||||
// Pass everything to the renderer
|
||||
renderer?.load(
|
||||
url: config.url,
|
||||
with: preset,
|
||||
headers: config.headers,
|
||||
startPosition: config.startPosition,
|
||||
externalSubtitles: config.externalSubtitles,
|
||||
initialSubtitleId: config.initialSubtitleId,
|
||||
initialAudioId: config.initialAudioId
|
||||
)
|
||||
|
||||
if config.autoplay {
|
||||
play()
|
||||
}
|
||||
|
||||
onLoad(["url": config.url.absoluteString])
|
||||
}
|
||||
|
||||
// Convenience method for simple loads
|
||||
func loadVideo(url: URL, headers: [String: String]? = nil) {
|
||||
loadVideo(config: VideoLoadConfig(url: url, headers: headers))
|
||||
}
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pausePlayback()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
renderer?.seek(to: position)
|
||||
}
|
||||
|
||||
func seekBy(offset: Double) {
|
||||
renderer?.seek(by: offset)
|
||||
}
|
||||
|
||||
func setSpeed(speed: Double) {
|
||||
renderer?.setSpeed(speed)
|
||||
}
|
||||
|
||||
func getSpeed() -> Double {
|
||||
return renderer?.getSpeed() ?? 1.0
|
||||
}
|
||||
|
||||
func isPaused() -> Bool {
|
||||
return renderer?.isPausedState ?? true
|
||||
}
|
||||
|
||||
func getCurrentPosition() -> Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
func getDuration() -> Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
func startPictureInPicture() {
|
||||
pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func isPictureInPictureSupported() -> Bool {
|
||||
return pipController?.isPictureInPictureSupported ?? false
|
||||
}
|
||||
|
||||
func isPictureInPictureActive() -> Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
func getSubtitleTracks() -> [[String: Any]] {
|
||||
return renderer?.getSubtitleTracks() ?? []
|
||||
}
|
||||
|
||||
func setSubtitleTrack(_ trackId: Int) {
|
||||
renderer?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
func disableSubtitles() {
|
||||
renderer?.disableSubtitles()
|
||||
}
|
||||
|
||||
func getCurrentSubtitleTrack() -> Int {
|
||||
return renderer?.getCurrentSubtitleTrack() ?? 0
|
||||
}
|
||||
|
||||
func addSubtitleFile(url: String, select: Bool = true) {
|
||||
renderer?.addSubtitleFile(url: url, select: select)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
func getAudioTracks() -> [[String: Any]] {
|
||||
return renderer?.getAudioTracks() ?? []
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
renderer?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
func getCurrentAudioTrack() -> Int {
|
||||
return renderer?.getCurrentAudioTrack() ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
func setSubtitlePosition(_ position: Int) {
|
||||
renderer?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
func setSubtitleScale(_ scale: Double) {
|
||||
renderer?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
func setSubtitleMarginY(_ margin: Int) {
|
||||
renderer?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
func setSubtitleAlignX(_ alignment: String) {
|
||||
renderer?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleAlignY(_ alignment: String) {
|
||||
renderer?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
func setSubtitleFontSize(_ size: Int) {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
displayLayer.controlTimebase = nil
|
||||
displayLayer.removeFromSuperlayer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MPVMetalRendererDelegate
|
||||
|
||||
extension MpvPlayerView: MPVMetalRendererDelegate {
|
||||
func renderer(_: MPVMetalRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.pipController?.isPictureInPictureActive == true {
|
||||
self.pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
self.onProgress([
|
||||
"position": position,
|
||||
"duration": duration,
|
||||
"progress": duration > 0 ? position / duration : 0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVMetalRenderer, didChangePause isPaused: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isPaused": isPaused,
|
||||
"isPlaying": !isPaused,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVMetalRenderer, didChangeLoading isLoading: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isLoading": isLoading,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVMetalRenderer, didBecomeReadyToSeek: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onPlaybackStateChange([
|
||||
"isReadyToSeek": didBecomeReadyToSeek,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func renderer(_: MPVMetalRenderer, didBecomeTracksReady: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onTracksReady([:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiPControllerDelegate
|
||||
|
||||
extension MpvPlayerView: PiPControllerDelegate {
|
||||
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool) {
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
renderer?.syncTimebase()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool) {
|
||||
renderer?.syncTimebase()
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
func pipControllerPlay(_ controller: PiPController) {
|
||||
play()
|
||||
}
|
||||
|
||||
func pipControllerPause(_ controller: PiPController) {
|
||||
pause()
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime) {
|
||||
let seconds = CMTimeGetSeconds(interval)
|
||||
let target = max(0, cachedPosition + seconds)
|
||||
seekTo(position: target)
|
||||
}
|
||||
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool {
|
||||
return intendedPlayState
|
||||
}
|
||||
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double {
|
||||
return getDuration()
|
||||
}
|
||||
}
|
||||
172
modules/mpv-player/ios/PiPController.swift
Normal file
172
modules/mpv-player/ios/PiPController.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
|
||||
protocol PiPControllerDelegate: AnyObject {
|
||||
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool)
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void)
|
||||
func pipControllerPlay(_ controller: PiPController)
|
||||
func pipControllerPause(_ controller: PiPController)
|
||||
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
||||
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
||||
func pipControllerDuration(_ controller: PiPController) -> Double
|
||||
}
|
||||
|
||||
final class PiPController: NSObject {
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
||||
|
||||
weak var delegate: PiPControllerDelegate?
|
||||
|
||||
var isPictureInPictureSupported: Bool {
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
var isPictureInPictureActive: Bool {
|
||||
return pipController?.isPictureInPictureActive ?? false
|
||||
}
|
||||
|
||||
var isPictureInPicturePossible: Bool {
|
||||
return pipController?.isPictureInPicturePossible ?? false
|
||||
}
|
||||
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
||||
super.init()
|
||||
setupPictureInPicture()
|
||||
}
|
||||
|
||||
private func setupPictureInPicture() {
|
||||
guard isPictureInPictureSupported,
|
||||
let displayLayer = sampleBufferDisplayLayer else {
|
||||
return
|
||||
}
|
||||
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: displayLayer,
|
||||
playbackDelegate: self
|
||||
)
|
||||
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = self
|
||||
pipController?.requiresLinearPlayback = false
|
||||
#if !os(tvOS)
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
#endif
|
||||
}
|
||||
|
||||
func startPictureInPicture() {
|
||||
guard let pipController = pipController,
|
||||
pipController.isPictureInPicturePossible else {
|
||||
return
|
||||
}
|
||||
|
||||
pipController.startPictureInPicture()
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updatePlaybackState() {
|
||||
if Thread.isMainThread {
|
||||
pipController?.invalidatePlaybackState()
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
extension PiPController: AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, willStartPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, didStartPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||
Logger.shared.log("Failed to start PiP: \(error.localizedDescription)", type: "Error")
|
||||
delegate?.pipController(self, didStartPictureInPicture: false)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, willStopPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
delegate?.pipController(self, didStopPictureInPicture: true)
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
||||
if playing {
|
||||
delegate?.pipControllerPlay(self)
|
||||
} else {
|
||||
delegate?.pipControllerPause(self)
|
||||
}
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
||||
delegate?.pipController(self, skipByInterval: skipInterval)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
return delegate?.pipControllerIsPlaying(self) ?? false
|
||||
}
|
||||
|
||||
var timeRangeForPlayback: CMTimeRange {
|
||||
let duration = delegate?.pipControllerDuration(self) ?? 0
|
||||
if duration > 0 {
|
||||
let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
return CMTimeRange(start: .zero, duration: cmDuration)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: .positiveInfinity)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
||||
return timeRangeForPlayback
|
||||
}
|
||||
|
||||
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
||||
return !isPlaying
|
||||
}
|
||||
|
||||
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) {
|
||||
if playing {
|
||||
delegate?.pipControllerPlay(self)
|
||||
} else {
|
||||
delegate?.pipControllerPause(self)
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
40
modules/mpv-player/ios/PlayerPreset.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
struct PlayerPreset: Identifiable, Hashable {
|
||||
enum Identifier: String, CaseIterable {
|
||||
case sdrRec709
|
||||
case hdr10
|
||||
case dolbyVisionP5
|
||||
case dolbyVisionP8
|
||||
}
|
||||
|
||||
struct Stream: Hashable {
|
||||
enum Source: Hashable {
|
||||
case remote(URL)
|
||||
case bundled(resource: String, withExtension: String)
|
||||
}
|
||||
|
||||
let source: Source
|
||||
let note: String
|
||||
|
||||
func resolveURL() -> URL? {
|
||||
switch source {
|
||||
case .remote(let url):
|
||||
return url
|
||||
case .bundled(let resource, let ext):
|
||||
return Bundle.main.url(forResource: resource, withExtension: ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id: Identifier
|
||||
let title: String
|
||||
let summary: String
|
||||
let stream: Stream?
|
||||
let commands: [[String]]
|
||||
|
||||
static var presets: [PlayerPreset] {
|
||||
let list: [PlayerPreset] = []
|
||||
return list
|
||||
}
|
||||
}
|
||||
97
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
97
modules/mpv-player/src/MpvPlayer.types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export type OnLoadEventPayload = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type OnPlaybackStateChangePayload = {
|
||||
isPaused?: boolean;
|
||||
isPlaying?: boolean;
|
||||
isLoading?: boolean;
|
||||
isReadyToSeek?: boolean;
|
||||
};
|
||||
|
||||
export type OnProgressEventPayload = {
|
||||
position: number;
|
||||
duration: number;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type OnErrorEventPayload = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type VideoSource = {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
externalSubtitles?: string[];
|
||||
startPosition?: number;
|
||||
autoplay?: boolean;
|
||||
/** MPV subtitle track ID to select on start (1-based, -1 to disable) */
|
||||
initialSubtitleId?: number;
|
||||
/** MPV audio track ID to select on start (1-based) */
|
||||
initialAudioId?: number;
|
||||
};
|
||||
|
||||
export type MpvPlayerViewProps = {
|
||||
source?: VideoSource;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
|
||||
onPlaybackStateChange?: (event: {
|
||||
nativeEvent: OnPlaybackStateChangePayload;
|
||||
}) => void;
|
||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
seekTo: (position: number) => Promise<void>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
getSpeed: () => Promise<number>;
|
||||
isPaused: () => Promise<boolean>;
|
||||
getCurrentPosition: () => Promise<number>;
|
||||
getDuration: () => Promise<number>;
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
stopPictureInPicture: () => Promise<void>;
|
||||
isPictureInPictureSupported: () => Promise<boolean>;
|
||||
isPictureInPictureActive: () => Promise<boolean>;
|
||||
// Subtitle controls
|
||||
getSubtitleTracks: () => Promise<SubtitleTrack[]>;
|
||||
setSubtitleTrack: (trackId: number) => Promise<void>;
|
||||
disableSubtitles: () => Promise<void>;
|
||||
getCurrentSubtitleTrack: () => Promise<number>;
|
||||
addSubtitleFile: (url: string, select?: boolean) => Promise<void>;
|
||||
// Subtitle positioning
|
||||
setSubtitlePosition: (position: number) => Promise<void>;
|
||||
setSubtitleScale: (scale: number) => Promise<void>;
|
||||
setSubtitleMarginY: (margin: number) => Promise<void>;
|
||||
setSubtitleAlignX: (alignment: "left" | "center" | "right") => Promise<void>;
|
||||
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
|
||||
setSubtitleFontSize: (size: number) => Promise<void>;
|
||||
// Audio controls
|
||||
getAudioTracks: () => Promise<AudioTrack[]>;
|
||||
setAudioTrack: (trackId: number) => Promise<void>;
|
||||
getCurrentAudioTrack: () => Promise<number>;
|
||||
}
|
||||
|
||||
export type SubtitleTrack = {
|
||||
id: number;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type AudioTrack = {
|
||||
id: number;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
codec?: string;
|
||||
channels?: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
102
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
102
modules/mpv-player/src/MpvPlayerView.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("MpvPlayer");
|
||||
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function MpvPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
seekBy: async (offset: number) => {
|
||||
await nativeRef.current?.seekBy(offset);
|
||||
},
|
||||
setSpeed: async (speed: number) => {
|
||||
await nativeRef.current?.setSpeed(speed);
|
||||
},
|
||||
getSpeed: async () => {
|
||||
return (await nativeRef.current?.getSpeed()) ?? 1.0;
|
||||
},
|
||||
isPaused: async () => {
|
||||
return (await nativeRef.current?.isPaused()) ?? true;
|
||||
},
|
||||
getCurrentPosition: async () => {
|
||||
return (await nativeRef.current?.getCurrentPosition()) ?? 0;
|
||||
},
|
||||
getDuration: async () => {
|
||||
return (await nativeRef.current?.getDuration()) ?? 0;
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return (
|
||||
(await nativeRef.current?.isPictureInPictureSupported()) ?? false
|
||||
);
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return (await nativeRef.current?.isPictureInPictureActive()) ?? false;
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return (await nativeRef.current?.getSubtitleTracks()) ?? [];
|
||||
},
|
||||
setSubtitleTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||
},
|
||||
disableSubtitles: async () => {
|
||||
await nativeRef.current?.disableSubtitles();
|
||||
},
|
||||
getCurrentSubtitleTrack: async () => {
|
||||
return (await nativeRef.current?.getCurrentSubtitleTrack()) ?? 0;
|
||||
},
|
||||
addSubtitleFile: async (url: string, select = true) => {
|
||||
await nativeRef.current?.addSubtitleFile(url, select);
|
||||
},
|
||||
setSubtitlePosition: async (position: number) => {
|
||||
await nativeRef.current?.setSubtitlePosition(position);
|
||||
},
|
||||
setSubtitleScale: async (scale: number) => {
|
||||
await nativeRef.current?.setSubtitleScale(scale);
|
||||
},
|
||||
setSubtitleMarginY: async (margin: number) => {
|
||||
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||
},
|
||||
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||
},
|
||||
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||
},
|
||||
setSubtitleFontSize: async (size: number) => {
|
||||
await nativeRef.current?.setSubtitleFontSize(size);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
return (await nativeRef.current?.getAudioTracks()) ?? [];
|
||||
},
|
||||
setAudioTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
getCurrentAudioTrack: async () => {
|
||||
return (await nativeRef.current?.getCurrentAudioTrack()) ?? 0;
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
2
modules/mpv-player/src/index.ts
Normal file
2
modules/mpv-player/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./MpvPlayer.types";
|
||||
export { default as MpvPlayerView } from "./MpvPlayerView";
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayer4Module"],
|
||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
protocol SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() -> Void
|
||||
func applicationDidEnterForeground() -> Void
|
||||
}
|
||||
|
||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// The app has become active.
|
||||
}
|
||||
|
||||
public func applicationWillResignActive(_ application: UIApplication) {
|
||||
// The app is about to become inactive.
|
||||
}
|
||||
|
||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterBackground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterForeground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillTerminate(_ application: UIApplication) {
|
||||
// The app is about to terminate.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
class VLCManager {
|
||||
static let shared = VLCManager()
|
||||
var listeners: [SimpleAppLifecycleListener] = []
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer4'
|
||||
s.version = '4.0.0a10'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '16' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'VLCKit', s.version
|
||||
s.tvos.dependency 'VLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayer4Module: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer4")
|
||||
View(VlcPlayer4View.self) {
|
||||
Prop("source") { (view: VlcPlayer4View, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayer4View, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayer4View) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayer4View) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayer4View) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayer4View) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayer4View, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer4View, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer4View) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: VlcPlayer4View, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import VLCKit
|
||||
import os
|
||||
|
||||
public class VLCPlayerView: UIView {
|
||||
func setupView(parent: UIView) {
|
||||
self.backgroundColor = .black
|
||||
self.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
||||
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
||||
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
||||
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
for subview in subviews {
|
||||
subview.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VLCPlayerWrapper: NSObject {
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
||||
private var updatePlayerState: (() -> Void)?
|
||||
private var updateVideoProgress: (() -> Void)?
|
||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
||||
|
||||
override public init() {
|
||||
super.init()
|
||||
player.delegate = self
|
||||
player.drawable = self
|
||||
player.scaleFactor = 0
|
||||
}
|
||||
|
||||
public func setup(
|
||||
parent: UIView,
|
||||
updatePlayerState: (() -> Void)?,
|
||||
updateVideoProgress: (() -> Void)?
|
||||
) {
|
||||
self.updatePlayerState = updatePlayerState
|
||||
self.updateVideoProgress = updateVideoProgress
|
||||
|
||||
player.delegate = self
|
||||
parent.addSubview(playerView)
|
||||
playerView.setupView(parent: parent)
|
||||
}
|
||||
|
||||
public func getPlayerView() -> UIView {
|
||||
return playerView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureDrawable
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
||||
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
||||
return self
|
||||
}
|
||||
|
||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
||||
{
|
||||
return { [weak self] controller in
|
||||
self?.pipController = controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureMediaControlling
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
||||
func mediaTime() -> Int64 {
|
||||
return player.time.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func mediaLength() -> Int64 {
|
||||
return player.media?.length.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||
player.jump(withOffset: Int32(offset), completion: completion)
|
||||
}
|
||||
|
||||
func isMediaSeekable() -> Bool {
|
||||
return player.isSeekable
|
||||
}
|
||||
|
||||
func isMediaPlaying() -> Bool {
|
||||
return player.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCDrawable
|
||||
extension VLCPlayerWrapper: VLCDrawable {
|
||||
public func addSubview(_ view: UIView) {
|
||||
playerView.addSubview(view)
|
||||
}
|
||||
|
||||
public func bounds() -> CGRect {
|
||||
return playerView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCMediaPlayerDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - self.lastProgressCall >= 1 {
|
||||
self.lastProgressCall = timeNow
|
||||
self.updateVideoProgress?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updatePlayerState?()
|
||||
|
||||
guard let pipController = self.pipController else { return }
|
||||
pipController.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class VlcPlayer4View: ExpoView {
|
||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayer4View")
|
||||
|
||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
private var isPaused: Bool = false
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalTrack: [String: String]?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
var hasSource = false
|
||||
var initialSeekPerformed = false
|
||||
// A flag variable determinging if we should perform the initial seek. Its either transcoding or offline playback. that makes
|
||||
var shouldPerformInitialSeek: Bool = false
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupVLC()
|
||||
setupNotifications()
|
||||
VLCManager.shared.listeners.append(self)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
private func setupVLC() {
|
||||
vlc.setup(
|
||||
parent: self,
|
||||
updatePlayerState: updatePlayerState,
|
||||
updateVideoProgress: updateVideoProgress
|
||||
)
|
||||
}
|
||||
|
||||
// Workaround: When playing an HLS video for the first time, seeking to a specific time immediately can cause a crash.
|
||||
// To avoid this, we wait until the video has started playing before performing the initial seek.
|
||||
func performInitialSeek() {
|
||||
guard !initialSeekPerformed,
|
||||
startPosition > 0,
|
||||
shouldPerformInitialSeek,
|
||||
vlc.player.isSeekable else { return }
|
||||
|
||||
initialSeekPerformed = true
|
||||
logger.debug("First time update, performing initial seek to \(self.startPosition) seconds")
|
||||
vlc.player.time = VLCTime(int: startPosition * 1000)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {
|
||||
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
||||
self.onPipStarted?(["pipStarted": isStarted])
|
||||
}
|
||||
self.vlc.pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
@objc func play() {
|
||||
self.vlc.player.play()
|
||||
self.isPaused = false
|
||||
logger.debug("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
self.vlc.player.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
let wasPlaying = vlc.player.isPlaying
|
||||
if wasPlaying {
|
||||
self.pause()
|
||||
}
|
||||
|
||||
if let duration = vlc.player.media?.length.intValue {
|
||||
logger.debug("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
|
||||
vlc.player.time = VLCTime(int: seekTime)
|
||||
self.updatePlayerState()
|
||||
|
||||
// Let mediaPlayerStateChanged handle play state change
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
logger.debug("Setting source...")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
for item in initOptions {
|
||||
let option = item.components(separatedBy: "=")
|
||||
mediaOptions.updateValue(
|
||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
||||
}
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
logger.error("Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
// Set shouldPeformIntial based on isTranscoding and is not a network stream
|
||||
self.shouldPerformInitialSeek = uri.contains("m3u8") || !isNetwork
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
|
||||
let media: VLCMedia!
|
||||
if isNetwork {
|
||||
logger.debug("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
logger.debug("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.vlc.player.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
logger.info("Playing...")
|
||||
// The Video is not transcoding so it its safe to seek to the start position.
|
||||
if !self.shouldPerformInitialSeek {
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
print("Setting audio track: \(trackIndex)")
|
||||
let track = self.vlc.player.audioTracks[trackIndex]
|
||||
track.isSelectedExclusively = true
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
return vlc.player.audioTracks.enumerated().map {
|
||||
return ["name": $1.trackName, "index": $0]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
||||
if trackIndex == -1 {
|
||||
logger.debug("Disabling all subtitles")
|
||||
for track in self.vlc.player.textTracks {
|
||||
track.isSelected = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let track = self.vlc.player.textTracks[trackIndex]
|
||||
track.isSelectedExclusively = true;
|
||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
logger.error("Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
if result == 0 {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
||||
} else {
|
||||
logger.debug("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
if self.vlc.player.textTracks.count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
||||
|
||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
||||
if let customSubtitle = customSubtitles.first(where: {
|
||||
$0.internalName == track.trackName
|
||||
}) {
|
||||
return ["name": customSubtitle.originalName, "index": index]
|
||||
} else {
|
||||
return ["name": track.trackName, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
logger.debug("Stopping media...")
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
vlc.player.stop()
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
vlc.getPlayerView().removeFromSuperview()
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard self.vlc.player.media != nil else { return }
|
||||
|
||||
let currentTimeMs = self.vlc.player.time.intValue
|
||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
||||
|
||||
logger.debug("Current time: \(currentTimeMs)")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
let player = self.vlc.player
|
||||
if player.isPlaying {
|
||||
performInitialSeek()
|
||||
}
|
||||
self.onVideoStateChange?([
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
"isPlaying": player.isPlaying,
|
||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
||||
"state": player.state.description,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
logger.debug("Deinitialization")
|
||||
performStop()
|
||||
VLCManager.shared.listeners.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimpleAppLifecycleListener
|
||||
extension VlcPlayer4View: SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() {
|
||||
logger.debug("Entering background")
|
||||
}
|
||||
|
||||
func applicationDidEnterForeground() {
|
||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
||||
logger.debug("Player view is missing. Adding back as subview")
|
||||
self.addSubview(self.vlc.getPlayerView())
|
||||
}
|
||||
|
||||
// Current solution to fixing black screen when re-entering application
|
||||
if let videoTrack = self.vlc.player.videoTracks.first(where: { $0.isSelected == true }),
|
||||
!self.vlc.isMediaPlaying()
|
||||
{
|
||||
videoTrack.isSelected = false
|
||||
videoTrack.isSelectedExclusively = true
|
||||
self.vlc.player.play()
|
||||
self.vlc.player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .error: return "Error"
|
||||
case .stopping: return "Stopping"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("VlcPlayer4");
|
||||
@@ -1,47 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
group = 'expo.modules.vlcplayer'
|
||||
version = '0.6.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
apply from: expoModulesCorePlugin
|
||||
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useDefaultAndroidSdkVersions()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
android {
|
||||
namespace "expo.modules.vlcplayer"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
||||
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
||||
object VLCManager: ReactActivityLifecycleListener {
|
||||
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
||||
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
||||
// listeners.forEach {
|
||||
// it.onCreate(activity, savedInstanceState)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onResume(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onResume(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onPause(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onPause(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onUserLeaveHint(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onUserLeaveHint(activity)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onDestroy(activity: Activity?) {
|
||||
// listeners.forEach {
|
||||
// it.onDestroy(activity)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class VlcPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
|
||||
OnActivityEntersForeground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onResume(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
OnActivityEntersBackground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onPause(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
View(VlcPlayerView::class) {
|
||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
||||
if (paused) {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: VlcPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
||||
view.setSubtitleURL(url, name)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoAspectRatio") { view: VlcPlayerView, aspectRatio: String? ->
|
||||
view.setVideoAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoScaleFactor") { view: VlcPlayerView, scaleFactor: Float ->
|
||||
view.setVideoScaleFactor(scaleFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import android.R
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
import expo.modules.core.logging.LogHandlers
|
||||
import expo.modules.core.logging.Logger
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import org.videolan.libvlc.LibVLC
|
||||
import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
|
||||
|
||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
|
||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
||||
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
||||
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
||||
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
||||
|
||||
private var libVLC: LibVLC? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private lateinit var videoLayout: VLCVideoLayout
|
||||
private var isPaused: Boolean = false
|
||||
private var lastReportedState: Int? = null
|
||||
private var lastReportedIsPlaying: Boolean? = null
|
||||
private var media : Media? = null
|
||||
private var timeLeft: Long? = null
|
||||
|
||||
private val onVideoProgress by EventDispatcher()
|
||||
private val onVideoStateChange by EventDispatcher()
|
||||
private val onVideoLoadEnd by EventDispatcher()
|
||||
private val onPipStarted by EventDispatcher()
|
||||
|
||||
private var startPosition: Int? = 0
|
||||
private var isMediaReady: Boolean = false
|
||||
private var externalTrack: Map<String, String>? = null
|
||||
private var externalSubtitles: List<Map<String, String>>? = null
|
||||
var hasSource: Boolean = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateInterval = 1000L // 1 second
|
||||
private val updateProgressRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
updateVideoProgress()
|
||||
handler.postDelayed(this, updateInterval)
|
||||
}
|
||||
}
|
||||
private val currentActivity get() = context.findActivity()
|
||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
||||
private val remoteActionFilter = IntentFilter()
|
||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
PIP_PLAY_PAUSE_ACTION -> {
|
||||
if (isPaused) play() else pause()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setupPipActions()
|
||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
||||
log.debug("Exiting PiP")
|
||||
timeLeft = mediaPlayer?.time
|
||||
pause()
|
||||
|
||||
// Setting the media after reattaching the view allows for a fast video view render
|
||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.media = media
|
||||
mediaPlayer?.play()
|
||||
timeLeft?.let { mediaPlayer?.time = it }
|
||||
mediaPlayer?.pause()
|
||||
|
||||
}
|
||||
}
|
||||
onPipStarted(mapOf(
|
||||
"pipStarted" to info.isInPictureInPictureMode
|
||||
))
|
||||
}
|
||||
|
||||
init {
|
||||
VLCManager.listeners.add(this)
|
||||
setupView()
|
||||
setupPiP()
|
||||
}
|
||||
|
||||
private fun setupView() {
|
||||
log.debug("Setting up view")
|
||||
setBackgroundColor(android.graphics.Color.WHITE)
|
||||
videoLayout = VLCVideoLayout(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
videoLayout.keepScreenOn = true
|
||||
addView(videoLayout)
|
||||
log.debug("View setup complete")
|
||||
}
|
||||
|
||||
private fun setupPiP() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
currentActivity.registerReceiver(
|
||||
actionReceiver,
|
||||
remoteActionFilter,
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
setupPipActions()
|
||||
currentActivity.apply {
|
||||
setPictureInPictureParams(getPipParams()!!)
|
||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun setupPipActions() {
|
||||
actions.clear()
|
||||
actions.addAll(
|
||||
listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
||||
"Rewind",
|
||||
"Rewind Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
rewindIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
||||
"Play",
|
||||
"Play Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
if (isPaused) 0 else 1,
|
||||
playPauseIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
||||
"Skip",
|
||||
"Skip Forward",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
forwardIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPipParams(): PictureInPictureParams? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
var builder = PictureInPictureParams.Builder()
|
||||
.setActions(actions)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder = builder.setAutoEnterEnabled(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun setSource(source: Map<String, Any>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Ignoring.")
|
||||
return
|
||||
}
|
||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
||||
externalSubtitles = source["externalSubtitles"] as? List<Map<String, String>>
|
||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
||||
|
||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
||||
initOptions.add("--start-time=$startPosition")
|
||||
|
||||
|
||||
val uri = source["uri"] as? String
|
||||
|
||||
// Handle video load start event
|
||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
||||
|
||||
libVLC = LibVLC(context, initOptions)
|
||||
mediaPlayer = MediaPlayer(libVLC)
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.setEventListener(this)
|
||||
|
||||
log.debug("Loading network file: $uri")
|
||||
media = Media(libVLC, Uri.parse(uri))
|
||||
mediaPlayer?.media = media
|
||||
|
||||
log.debug("Debug: Media options: $mediaOptions")
|
||||
// media.addOptions(mediaOptions)
|
||||
|
||||
// Set initial external subtitles immediately like iOS
|
||||
setInitialExternalSubtitles()
|
||||
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
log.debug("Playing...")
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
mediaPlayer?.play()
|
||||
isPaused = false
|
||||
handler.post(updateProgressRunnable) // Start updating progress
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mediaPlayer?.pause()
|
||||
isPaused = true
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun seekTo(time: Int) {
|
||||
mediaPlayer?.let { player ->
|
||||
val wasPlaying = player.isPlaying
|
||||
if (wasPlaying) {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
val duration = player.length.toInt()
|
||||
val seekTime = if (time > duration) duration - 1000 else time
|
||||
player.time = seekTime.toLong()
|
||||
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||
|
||||
return trackDescriptions.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setSpuTrack(trackIndex)
|
||||
}
|
||||
|
||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
fun setVideoAspectRatio(aspectRatio: String?) {
|
||||
log.debug("Setting video aspect ratio: $aspectRatio")
|
||||
mediaPlayer?.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
fun setVideoScaleFactor(scaleFactor: Float) {
|
||||
log.debug("Setting video scale factor: $scaleFactor")
|
||||
mediaPlayer?.scale = scaleFactor
|
||||
}
|
||||
|
||||
private fun setInitialExternalSubtitles() {
|
||||
externalSubtitles?.let { subtitles ->
|
||||
for (subtitle in subtitles) {
|
||||
val subtitleName = subtitle["name"]
|
||||
val subtitleURL = subtitle["DeliveryUrl"]
|
||||
if (!subtitleName.isNullOrEmpty() && !subtitleURL.isNullOrEmpty()) {
|
||||
log.debug("Setting external subtitle: $subtitleName $subtitleURL")
|
||||
setSubtitleURL(subtitleURL, subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
log.debug("onDetachedFromWindow")
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
currentActivity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
currentActivity.unregisterReceiver(actionReceiver)
|
||||
}
|
||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
VLCManager.listeners.clear()
|
||||
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
media?.release()
|
||||
mediaPlayer?.release()
|
||||
libVLC?.release()
|
||||
mediaPlayer = null
|
||||
media = null
|
||||
libVLC = null
|
||||
}
|
||||
|
||||
override fun onEvent(event: MediaPlayer.Event) {
|
||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
||||
when (event.type) {
|
||||
MediaPlayer.Event.Playing,
|
||||
MediaPlayer.Event.Paused,
|
||||
MediaPlayer.Event.Stopped,
|
||||
MediaPlayer.Event.Buffering,
|
||||
MediaPlayer.Event.EndReached,
|
||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
||||
MediaPlayer.Event.TimeChanged -> {
|
||||
// Do nothing here, as we are updating progress every 1 second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
||||
val player = mediaPlayer ?: return
|
||||
val currentState = event.type
|
||||
|
||||
val stateInfo = mutableMapOf<String, Any>(
|
||||
"target" to "null", // Replace with actual target if needed
|
||||
"currentTime" to player.time.toInt(),
|
||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
||||
"error" to false,
|
||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
||||
)
|
||||
|
||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
||||
when (currentState) {
|
||||
MediaPlayer.Event.Playing ->
|
||||
stateInfo["state"] = "Playing"
|
||||
MediaPlayer.Event.Paused ->
|
||||
stateInfo["state"] = "Paused"
|
||||
MediaPlayer.Event.Buffering ->
|
||||
stateInfo["state"] = "Buffering"
|
||||
MediaPlayer.Event.EncounteredError -> {
|
||||
stateInfo["state"] = "Error"
|
||||
onVideoLoadEnd(stateInfo);
|
||||
}
|
||||
MediaPlayer.Event.Opening ->
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||
lastReportedState = currentState
|
||||
lastReportedIsPlaying = player.isPlaying
|
||||
onVideoStateChange(stateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateVideoProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
|
||||
val currentTimeMs = player.time.toInt()
|
||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
||||
// Set subtitle URL if available
|
||||
if (player.isPlaying && !isMediaReady) {
|
||||
isMediaReady = true
|
||||
externalTrack?.let {
|
||||
val name = it["name"]
|
||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
||||
setSubtitleURL(deliveryUrl, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
onVideoProgress(mapOf(
|
||||
"currentTime" to currentTimeMs,
|
||||
"duration" to durationMs
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(activity: Activity?) {
|
||||
log.debug("Pausing activity...")
|
||||
}
|
||||
|
||||
|
||||
override fun onResume(activity: Activity?) {
|
||||
log.debug("Resuming activity...")
|
||||
if (isPaused) play()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
||||
var context = this
|
||||
while (context is ContextWrapper) {
|
||||
if (context is androidx.activity.ComponentActivity) return context
|
||||
context = context.baseContext
|
||||
}
|
||||
throw IllegalStateException("Failed to find ComponentActivity")
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer'
|
||||
s.version = '3.6.1b1'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'MobileVLCKit', s.version
|
||||
s.tvos.dependency 'TVVLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,78 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
View(VlcPlayerView.self) {
|
||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { (view: VlcPlayerView, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoAspectRatio") { (view: VlcPlayerView, aspectRatio: String?) in
|
||||
view.setVideoAspectRatio(aspectRatio)
|
||||
}
|
||||
|
||||
AsyncFunction("setVideoScaleFactor") { (view: VlcPlayerView, scaleFactor: Float) in
|
||||
view.setVideoScaleFactor(scaleFactor)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
#if os(tvOS)
|
||||
import TVVLCKit
|
||||
#else
|
||||
import MobileVLCKit
|
||||
#endif
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
private var mediaPlayer: VLCMediaPlayer?
|
||||
private var videoView: UIView?
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
private var isPaused: Bool = false
|
||||
private var currentGeometryCString: [CChar]?
|
||||
private var lastReportedState: VLCMediaPlayerState?
|
||||
private var lastReportedIsPlaying: Bool?
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
private var externalTrack: [String: String]?
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
var hasSource = false
|
||||
var isTranscoding = false
|
||||
private var initialSeekPerformed: Bool = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupView()
|
||||
setupNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupView() {
|
||||
DispatchQueue.main.async {
|
||||
self.backgroundColor = .black
|
||||
self.videoView = UIView()
|
||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if let videoView = self.videoView {
|
||||
self.addSubview(videoView)
|
||||
NSLayoutConstraint.activate([
|
||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {}
|
||||
|
||||
@objc func play() {
|
||||
self.mediaPlayer?.play()
|
||||
self.isPaused = false
|
||||
print("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
self.mediaPlayer?.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
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 {
|
||||
self.play()
|
||||
}
|
||||
self.updatePlayerState()
|
||||
} else {
|
||||
print("Error: Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
print("Error: Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
self.isTranscoding = uri.contains("m3u8")
|
||||
|
||||
if !self.isTranscoding, self.startPosition > 0 {
|
||||
initOptions.append("--start-time=\(self.startPosition)")
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
||||
self.mediaPlayer?.delegate = self
|
||||
self.mediaPlayer?.drawable = self.videoView
|
||||
self.mediaPlayer?.scaleFactor = 0
|
||||
self.initialSeekPerformed = false
|
||||
|
||||
let media: VLCMedia
|
||||
if isNetwork {
|
||||
print("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
print("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.mediaPlayer?.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
print("Playing...")
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return zip(trackNames, trackIndexes).map { name, index in
|
||||
return ["name": name, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
||||
print(
|
||||
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
print("Error: Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
|
||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
if let result = result {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
print("Subtitle added with result: \(result) \(internalName)")
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
} else {
|
||||
print("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
guard let mediaPlayer = self.mediaPlayer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
||||
print("Debug: Number of subtitle tracks: \(count)")
|
||||
|
||||
guard count > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tracks: [[String: Any]] = []
|
||||
|
||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
||||
{
|
||||
for (index, name) in zip(indexes, names) {
|
||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
||||
} else {
|
||||
tracks.append(["name": name, "index": index.intValue])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("Debug: Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func setVideoAspectRatio(_ aspectRatio: String?) {
|
||||
DispatchQueue.main.async {
|
||||
if let aspectRatio = aspectRatio {
|
||||
// Convert String to C string for VLC
|
||||
let cString = strdup(aspectRatio)
|
||||
self.mediaPlayer?.videoAspectRatio = cString
|
||||
} else {
|
||||
// Reset to default (let VLC determine aspect ratio)
|
||||
self.mediaPlayer?.videoAspectRatio = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setVideoScaleFactor(_ scaleFactor: Float) {
|
||||
DispatchQueue.main.async {
|
||||
self.mediaPlayer?.scaleFactor = scaleFactor
|
||||
print("Set video scale factor: \(scaleFactor)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
mediaPlayer?.stop()
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
videoView?.removeFromSuperview()
|
||||
videoView = nil
|
||||
|
||||
// Release the media player
|
||||
mediaPlayer?.delegate = nil
|
||||
mediaPlayer = nil
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
|
||||
let currentTimeMs = player.time.intValue
|
||||
let durationMs = player.media?.length.intValue ?? 0
|
||||
|
||||
|
||||
print("Debug: Current time: \(currentTimeMs)")
|
||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
||||
if self.isTranscoding, !self.initialSeekPerformed, self.startPosition > 0 {
|
||||
player.time = VLCTime(int: self.startPosition * 1000)
|
||||
self.initialSeekPerformed = true
|
||||
}
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
performStop()
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
// self?.updateVideoProgress()
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - lastProgressCall >= 1 {
|
||||
lastProgressCall = timeNow
|
||||
updateVideoProgress()
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
self.updatePlayerState()
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
guard let player = self.mediaPlayer else { return }
|
||||
let currentState = player.state
|
||||
|
||||
var stateInfo: [String: Any] = [
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
]
|
||||
|
||||
if player.isPlaying {
|
||||
stateInfo["isPlaying"] = true
|
||||
stateInfo["isBuffering"] = false
|
||||
stateInfo["state"] = "Playing"
|
||||
} else {
|
||||
stateInfo["isPlaying"] = false
|
||||
stateInfo["state"] = "Paused"
|
||||
}
|
||||
|
||||
if player.state == VLCMediaPlayerState.buffering {
|
||||
stateInfo["isBuffering"] = true
|
||||
stateInfo["state"] = "Buffering"
|
||||
} else if player.state == VLCMediaPlayerState.error {
|
||||
print("player.state ~ error")
|
||||
stateInfo["state"] = "Error"
|
||||
self.onVideoLoadEnd?(stateInfo)
|
||||
} else if player.state == VLCMediaPlayerState.opening {
|
||||
print("player.state ~ opening")
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if self.lastReportedState != currentState
|
||||
|| self.lastReportedIsPlaying != player.isPlaying
|
||||
{
|
||||
self.lastReportedState = currentState
|
||||
self.lastReportedIsPlaying = player.isPlaying
|
||||
self.onVideoStateChange?(stateInfo)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension VlcPlayerView: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .ended: return "Ended"
|
||||
case .error: return "Error"
|
||||
case .esAdded: return "ESAdded"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule("VlcPlayer");
|
||||
27
package.json
27
package.json
@@ -68,7 +68,7 @@
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "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-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
@@ -103,21 +103,20 @@
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@biomejs/biome": "^2.3.5",
|
||||
"@react-native-community/cli": "^20.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@babel/core": "7.28.5",
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@react-native-community/cli": "20.0.2",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.17.11",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.6",
|
||||
"react-test-renderer": "19.1.1",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"expo": {
|
||||
"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>
|
||||
| ((current: JobStatus) => Partial<JobStatus>),
|
||||
) => {
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== processId) return p;
|
||||
const newStatus =
|
||||
typeof updater === "function" ? updater(p) : updater;
|
||||
return {
|
||||
...p,
|
||||
...newStatus,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setProcesses((prev) => {
|
||||
const processIndex = prev.findIndex((p) => p.id === processId);
|
||||
if (processIndex === -1) return prev;
|
||||
|
||||
const currentProcess = prev[processIndex];
|
||||
if (!currentProcess) return prev;
|
||||
|
||||
const newStatus =
|
||||
typeof updater === "function" ? updater(currentProcess) : updater;
|
||||
|
||||
// Create new array with updated process
|
||||
const newProcesses = [...prev];
|
||||
newProcesses[processIndex] = {
|
||||
...currentProcess,
|
||||
...newStatus,
|
||||
};
|
||||
|
||||
return newProcesses;
|
||||
});
|
||||
},
|
||||
[setProcesses],
|
||||
);
|
||||
|
||||
const removeProcess = useCallback(
|
||||
(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
|
||||
taskMapRef.current.forEach((processId, taskId) => {
|
||||
if (processId === id) {
|
||||
taskMapRef.current.delete(taskId);
|
||||
}
|
||||
});
|
||||
// Find and remove from task map
|
||||
taskMapRef.current.forEach((processId, taskId) => {
|
||||
if (processId === id) {
|
||||
taskMapRef.current.delete(taskId);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
[setProcesses],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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 { 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
|
||||
*/
|
||||
@@ -60,7 +65,7 @@ export async function sendDownloadNotification(
|
||||
body: string,
|
||||
data?: Record<string, any>,
|
||||
): Promise<void> {
|
||||
if (Platform.isTV) return;
|
||||
if (Platform.isTV || !Notifications) return;
|
||||
|
||||
try {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.47.1" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.48.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.47.1"`,
|
||||
}, DeviceId="${deviceId}", Version="0.48.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
newWebSocket.onmessage = (e) => {
|
||||
try {
|
||||
const message = JSON.parse(e.data);
|
||||
console.log("[WS] Received message:", message);
|
||||
setLastMessage(message); // Store the last message in context
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
@@ -124,12 +123,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const handlePlayCommand = useCallback(
|
||||
(data: any) => {
|
||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||
console.warn("[WS] Received Play command with no items");
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = data.ItemIds[0];
|
||||
console.log(`[WS] Handling Play command for item: ${itemId}`);
|
||||
|
||||
router.push({
|
||||
pathname: "/(auth)/player/direct-player",
|
||||
|
||||
@@ -3,18 +3,51 @@
|
||||
|
||||
const isTV = process.env?.EXPO_TV === "1";
|
||||
|
||||
module.exports = {
|
||||
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,
|
||||
},
|
||||
const disableForTV = (_moduleName) =>
|
||||
isTV
|
||||
? {
|
||||
platforms: {
|
||||
ios: null,
|
||||
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: {},
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user