mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
Compare commits
11 Commits
chore/enfo
...
cleanup/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7cd413882 | ||
|
|
4d508a4315 | ||
|
|
d7fbe992ae | ||
|
|
915a4febbb | ||
|
|
88163eb531 | ||
|
|
46bd2a784e | ||
|
|
0a36fdfbec | ||
|
|
45d1f752d6 | ||
|
|
54ee507209 | ||
|
|
13973dc53a | ||
|
|
8eeb571f33 |
29
.gitattributes
vendored
29
.gitattributes
vendored
@@ -1,28 +1 @@
|
|||||||
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||||
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
|
||||||
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# Windows-only scripts must stay CRLF
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
|
|
||||||
# Binary assets — never touched / never normalised
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.webp binary
|
|
||||||
*.ico binary
|
|
||||||
*.icns binary
|
|
||||||
*.ttf binary
|
|
||||||
*.otf binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.mp3 binary
|
|
||||||
*.mp4 binary
|
|
||||||
*.mov binary
|
|
||||||
*.pdf binary
|
|
||||||
*.keystore binary
|
|
||||||
*.jks binary
|
|
||||||
*.p12 binary
|
|
||||||
|
|||||||
102
.github/workflows/crowdin.yml
vendored
102
.github/workflows/crowdin.yml
vendored
@@ -1,51 +1,51 @@
|
|||||||
name: 🌐 Translation Sync
|
name: 🌐 Translation Sync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop]
|
branches: [develop]
|
||||||
paths:
|
paths:
|
||||||
- "translations/**"
|
- "translations/**"
|
||||||
- "crowdin.yml"
|
- "crowdin.yml"
|
||||||
- "i18n.ts"
|
- "i18n.ts"
|
||||||
- ".github/workflows/crowdin.yml"
|
- ".github/workflows/crowdin.yml"
|
||||||
# Run weekly to pull new translations
|
# Run weekly to pull new translations
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-translations:
|
sync-translations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
localization_branch_name: I10n_crowdin_translations
|
localization_branch_name: I10n_crowdin_translations
|
||||||
create_pull_request: true
|
create_pull_request: true
|
||||||
pull_request_title: "feat: New Crowdin Translations"
|
pull_request_title: "feat: New Crowdin Translations"
|
||||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||||
pull_request_base_branch_name: "develop"
|
pull_request_base_branch_name: "develop"
|
||||||
pull_request_labels: "🌐 translation"
|
pull_request_labels: "🌐 translation"
|
||||||
# Quality control options
|
# Quality control options
|
||||||
skip_untranslated_strings: false
|
skip_untranslated_strings: false
|
||||||
skip_untranslated_files: false
|
skip_untranslated_files: false
|
||||||
export_only_approved: false
|
export_only_approved: false
|
||||||
# Commit customization
|
# Commit customization
|
||||||
commit_message: "feat(i18n): update translations from Crowdin"
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Dependencies and Package Managers
|
# Dependencies and Package Managers
|
||||||
node_modules/
|
node_modules/
|
||||||
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -20,8 +21,10 @@ web-build/
|
|||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|
||||||
# Native module build outputs (any module)
|
# Module-specific Builds
|
||||||
modules/*/android/build/
|
modules/mpv-player/android/build
|
||||||
|
modules/player/android
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
# Generated Applications
|
# Generated Applications
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
@@ -66,6 +69,10 @@ certs/
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
/modules/sf-player/android/build
|
||||||
|
/modules/music-controls/android/build
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -143,14 +143,6 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
|
||||||
- Simple content modal
|
|
||||||
- Modal with custom snap points
|
|
||||||
- Complex component in modal
|
|
||||||
- Success/error modals triggered from functions
|
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View className='pl-1.5'>
|
<View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
|
||||||
return this.valueOf() * (60).minutesToMilliseconds();
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
declare global {
|
|
||||||
interface String {
|
|
||||||
toTitle(): string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
|
||||||
return this.replaceAll("_", " ").replace(
|
|
||||||
/\w\S*/g,
|
|
||||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example Usage of Global Modal
|
|
||||||
*
|
|
||||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
|
||||||
* You can delete this file after understanding how it works.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 1: Simple Content Modal
|
|
||||||
*/
|
|
||||||
export const SimpleModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This is a simple modal with just some text content.
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400'>
|
|
||||||
Swipe down or tap outside to close.
|
|
||||||
</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 2: Modal with Custom Snap Points
|
|
||||||
*/
|
|
||||||
export const CustomSnapPointsExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6' style={{ minHeight: 400 }}>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Custom Snap Points
|
|
||||||
</Text>
|
|
||||||
<Text className='text-white mb-4'>
|
|
||||||
This modal has custom snap points (25%, 50%, 90%).
|
|
||||||
</Text>
|
|
||||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
Try dragging the modal to different heights!
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>,
|
|
||||||
{
|
|
||||||
snapPoints: ["25%", "50%", "90%"],
|
|
||||||
enableDynamicSizing: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 3: Complex Component in Modal
|
|
||||||
*/
|
|
||||||
const SettingsModalContent = () => {
|
|
||||||
const { hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const settings = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Notifications",
|
|
||||||
icon: "notifications-outline" as const,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Auto-play",
|
|
||||||
icon: "play-outline" as const,
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
|
||||||
|
|
||||||
{settings.map((setting, index) => (
|
|
||||||
<View
|
|
||||||
key={setting.id}
|
|
||||||
className={`flex-row items-center justify-between py-4 ${
|
|
||||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center gap-3'>
|
|
||||||
<Ionicons name={setting.icon} size={24} color='white' />
|
|
||||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`w-12 h-7 rounded-full ${
|
|
||||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
|
||||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
|
||||||
}`}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={hideModal}
|
|
||||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComplexModalExample = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
|
||||||
showModal(<SettingsModalContent />);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenModal}
|
|
||||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
|
||||||
*/
|
|
||||||
export const useShowSuccessModal = () => {
|
|
||||||
const { showModal } = useGlobalModal();
|
|
||||||
|
|
||||||
return (message: string) => {
|
|
||||||
showModal(
|
|
||||||
<View className='p-6 items-center'>
|
|
||||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
|
||||||
<Ionicons name='checkmark' size={48} color='white' />
|
|
||||||
</View>
|
|
||||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
|
||||||
<Text className='text-white text-center'>{message}</Text>
|
|
||||||
</View>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Demo Component
|
|
||||||
*/
|
|
||||||
export const GlobalModalDemo = () => {
|
|
||||||
const showSuccess = useShowSuccessModal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-6 gap-4'>
|
|
||||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
|
||||||
Global Modal Examples
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SimpleModalExample />
|
|
||||||
<CustomSnapPointsExample />
|
|
||||||
<ComplexModalExample />
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => showSuccess("Operation completed successfully!")}
|
|
||||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
|
||||||
>
|
|
||||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
type LayoutChangeEvent,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
|
||||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
|
||||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
|
||||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
|
||||||
const [triggerSize, setTriggerSize] = useState<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = e.nativeEvent.layout;
|
|
||||||
setTriggerSize((prev) =>
|
|
||||||
prev && prev.width === width && prev.height === height
|
|
||||||
? prev
|
|
||||||
: { width, height },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
|
||||||
// height depends on the parent while the parent depends on the Host — a
|
|
||||||
// circular dependency that collapses to 0 for any selector nested more than
|
|
||||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
|
||||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
|
||||||
// fills a concrete box.
|
|
||||||
return (
|
return (
|
||||||
<View style={triggerSize ?? { opacity: 0 }}>
|
<View>
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
|
||||||
<View
|
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
|
||||||
pointerEvents='none'
|
|
||||||
aria-hidden
|
|
||||||
onLayout={handleMeasureTrigger}
|
|
||||||
>
|
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import {
|
import {
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
chapterStartsMs,
|
chapterStartsMs,
|
||||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterListProps) {
|
}: ChapterListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const safeArea = useControlsSafeAreaInsets();
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
@@ -79,7 +81,17 @@ function ChapterListComponent({
|
|||||||
supportedOrientations={["portrait", "landscape"]}
|
supportedOrientations={["portrait", "landscape"]}
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
<Pressable
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
style={[
|
||||||
|
styles.sheet,
|
||||||
|
{
|
||||||
|
marginLeft: safeArea.left,
|
||||||
|
marginRight: safeArea.right,
|
||||||
|
paddingBottom: safeArea.bottom,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -160,14 +172,12 @@ const styles = StyleSheet.create({
|
|||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.background,
|
backgroundColor: Colors.background,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
|
||||||
if (!url)
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='p-4 rounded-xl overflow-hidden '>
|
|
||||||
<Image
|
|
||||||
source={{ uri: url }}
|
|
||||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: "32%",
|
|
||||||
}}
|
|
||||||
className='flex flex-col'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
|
||||||
/>
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
|
||||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -133,7 +133,6 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
|
||||||
|
|
||||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
|
|
||||||
export function TabBarIcon({
|
|
||||||
style,
|
|
||||||
...rest
|
|
||||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
|
||||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
type MoviePosterProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
|
||||||
item,
|
|
||||||
showProgress = false,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(() => {
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const [progress, _setProgress] = useState(
|
|
||||||
item.UserData?.PlayedPercentage || 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const blurhash = useMemo(() => {
|
|
||||||
const key = item.ImageTags?.Primary as string;
|
|
||||||
return item.ImageBlurHashes?.Primary?.[key];
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
|
||||||
<Image
|
|
||||||
placeholder={{
|
|
||||||
blurhash,
|
|
||||||
}}
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={
|
|
||||||
url
|
|
||||||
? {
|
|
||||||
uri: url,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<WatchedIndicator item={item} />
|
|
||||||
{showProgress && progress > 0 && (
|
|
||||||
<View className='h-1 bg-red-600 w-full' />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
type PosterProps = {
|
|
||||||
id?: string;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(
|
|
||||||
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!url || !id)
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='border border-neutral-900'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
|
||||||
<Image
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit='cover'
|
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ParentPoster;
|
|
||||||
@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const hasMovies = movieResults && movieResults.length > 0;
|
|
||||||
const hasTv = tvResults && tvResults.length > 0;
|
|
||||||
const hasPersons = personResults && personResults.length > 0;
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||||
|
keeps focus while typing, otherwise the first result would re-grab
|
||||||
|
focus on every keystroke as results re-render. The user navigates
|
||||||
|
down to the grid manually. */}
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={hasMovies}
|
isFirstSection={false}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={!hasMovies && hasTv}
|
isFirstSection={false}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
isFirstSection={false}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
module). It renders the native search bar + grid keyboard and
|
module). It renders the native search bar + grid keyboard and
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
our own results grid renders below. */}
|
our own results grid renders below. */}
|
||||||
|
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||||
|
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||||
|
margins squeeze the bar's width and clip that trailing hint, so let
|
||||||
|
the native view span the full width and own its own insets. */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
|
||||||
height: SEARCH_AREA_HEIGHT,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section, index) => (
|
{sections.map((section) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
isFirstSection={index === 0}
|
// Never auto-focus a result. The native search field owns focus
|
||||||
|
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||||
|
// every keystroke as results re-render. User navigates down to the
|
||||||
|
// grid manually.
|
||||||
|
isFirstSection={false}
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentInset={{
|
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||||
left: edgePadding,
|
// contentOffset only applies on initial mount; since this FlatList is
|
||||||
right: edgePadding,
|
// reused across searches (stable key), a second search left the inset
|
||||||
}}
|
// without the offset and the grid snapped flush to the left edge.
|
||||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: edgePadding,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
|
||||||
import { ListItem } from "../list/ListItem";
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
|
||||||
<ListItem
|
|
||||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
|
||||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
|
||||||
title={t("home.settings.dashboard.sessions_title")}
|
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function DownloadSettings() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
@@ -75,9 +75,6 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chapter props
|
|
||||||
chapterPositions?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
chapterPositions = [],
|
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
left: insets.left,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
bottom: Math.max(insets.bottom - 17, 0),
|
||||||
bottom:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? Math.max(insets.bottom - 17, 0)
|
|
||||||
: 0,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
{hasChapters && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setChapterListVisible(true)}
|
|
||||||
hitSlop={10}
|
|
||||||
className='justify-center mr-4'
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.open")}
|
|
||||||
>
|
|
||||||
<Ionicons name='bookmarks' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center ml-4'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
right: insets.right,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
|
|||||||
hasNextChapter,
|
hasNextChapter,
|
||||||
goToPreviousChapter,
|
goToPreviousChapter,
|
||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
chapterPositions,
|
|
||||||
} = useChapterNavigation({
|
} = useChapterNavigation({
|
||||||
chapters: item.Chapters,
|
chapters: item.Chapters,
|
||||||
progress,
|
progress,
|
||||||
@@ -585,7 +584,6 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
chapterPositions={chapterPositions}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -17,10 +16,10 @@ import {
|
|||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getDownloadedEpisodesForSeason,
|
getDownloadedEpisodesForSeason,
|
||||||
getDownloadedSeasonNumbers,
|
getDownloadedSeasonNumbers,
|
||||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,12 +180,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop:
|
paddingTop: insets.top,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
paddingLeft: insets.left,
|
||||||
paddingLeft:
|
paddingRight: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
|
||||||
paddingRight:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const { orientation, lockOrientation } = useOrientation();
|
const { orientation, lockOrientation } = useOrientation();
|
||||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
top: insets.top,
|
||||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
left: insets.left,
|
||||||
right:
|
right: insets.right,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
|
||||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
|
|
||||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { settings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
left: Math.max(insets.left, 48) + 20,
|
left: Math.max(insets.left, 48) + 20,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
top:
|
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
|
||||||
left:
|
|
||||||
(settings?.safeAreaInControlsEnabled ?? true)
|
|
||||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
|
||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
|
||||||
{ label: "English", value: "eng" },
|
|
||||||
{ label: "Spanish", value: "spa" },
|
|
||||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
|
||||||
{ label: "Hindi", value: "hin" },
|
|
||||||
{ label: "Arabic", value: "ara" },
|
|
||||||
{ label: "French", value: "fra" },
|
|
||||||
{ label: "Russian", value: "rus" },
|
|
||||||
{ label: "Portuguese", value: "por" },
|
|
||||||
{ label: "Japanese", value: "jpn" },
|
|
||||||
{ label: "German", value: "deu" },
|
|
||||||
{ label: "Italian", value: "ita" },
|
|
||||||
{ label: "Korean", value: "kor" },
|
|
||||||
{ label: "Turkish", value: "tur" },
|
|
||||||
{ label: "Dutch", value: "nld" },
|
|
||||||
{ label: "Polish", value: "pol" },
|
|
||||||
{ label: "Vietnamese", value: "vie" },
|
|
||||||
{ label: "Thai", value: "tha" },
|
|
||||||
{ label: "Indonesian", value: "ind" },
|
|
||||||
{ label: "Greek", value: "ell" },
|
|
||||||
{ label: "Swedish", value: "swe" },
|
|
||||||
{ label: "Danish", value: "dan" },
|
|
||||||
{ label: "Norwegian", value: "nor" },
|
|
||||||
{ label: "Finnish", value: "fin" },
|
|
||||||
{ label: "Czech", value: "ces" },
|
|
||||||
{ label: "Hungarian", value: "hun" },
|
|
||||||
{ label: "Romanian", value: "ron" },
|
|
||||||
{ label: "Ukrainian", value: "ukr" },
|
|
||||||
{ label: "Hebrew", value: "heb" },
|
|
||||||
{ label: "Bengali", value: "ben" },
|
|
||||||
{ label: "Punjabi", value: "pan" },
|
|
||||||
{ label: "Tagalog", value: "tgl" },
|
|
||||||
{ label: "Swahili", value: "swa" },
|
|
||||||
{ label: "Malay", value: "msa" },
|
|
||||||
{ label: "Persian", value: "fas" },
|
|
||||||
{ label: "Urdu", value: "urd" },
|
|
||||||
];
|
|
||||||
18
hooks/useControlsSafeAreaInsets.ts
Normal file
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
type EdgeInsets,
|
||||||
|
useSafeAreaInsets,
|
||||||
|
} from "react-native-safe-area-context";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||||
|
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||||
|
* returns zero insets so controls can sit flush against the screen edges.
|
||||||
|
*/
|
||||||
|
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||||
|
};
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
|
|
||||||
export const useControlsVisibility = (timeout = 3000) => {
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
|
|
||||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const showControls = useCallback(() => {
|
|
||||||
opacity.value = 1;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
hideControlsTimerRef.current = setTimeout(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
}, timeout);
|
|
||||||
}, [timeout]);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
|
||||||
opacity.value = 0;
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (hideControlsTimerRef.current) {
|
|
||||||
clearTimeout(hideControlsTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { opacity, showControls, hideControls };
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export const useDownloadedFileOpener = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const openFile = useCallback(
|
|
||||||
async (item: BaseItemDto) => {
|
|
||||||
if (!item.Id) {
|
|
||||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
|
||||||
console.error("Attempted to open a file without an ID.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setOfflineSettings, setPlayUrl, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openFile };
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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 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,
|
|
||||||
isCloseToBlack,
|
|
||||||
itemThemeColorAtom,
|
|
||||||
} from "@/utils/atoms/primaryColor";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
|
||||||
*
|
|
||||||
* @param item - The BaseItemDto object representing the item.
|
|
||||||
* @param disabled - A boolean flag to disable color extraction.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const useImageColors = ({
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
url?: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
|
||||||
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const source = useMemo(() => {
|
|
||||||
if (!api) return;
|
|
||||||
if (url) return { uri: url };
|
|
||||||
if (item)
|
|
||||||
return getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 80,
|
|
||||||
width: 300,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}, [api, item, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTv) return;
|
|
||||||
if (disabled) return;
|
|
||||||
if (source?.uri) {
|
|
||||||
const _primary = storage.getString(`${source.uri}-primary`);
|
|
||||||
const _text = storage.getString(`${source.uri}-text`);
|
|
||||||
|
|
||||||
if (_primary && _text) {
|
|
||||||
setPrimaryColor({
|
|
||||||
primary: _primary,
|
|
||||||
text: _text,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract colors from the image
|
|
||||||
if (!ImageColors?.getColors) return;
|
|
||||||
|
|
||||||
ImageColors.getColors(source.uri, {
|
|
||||||
fallback: "#fff",
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
|
||||||
let primary = "#fff";
|
|
||||||
let text = "#000";
|
|
||||||
let backup = "#fff";
|
|
||||||
|
|
||||||
// Select the appropriate color based on the platform
|
|
||||||
if (colors.platform === "android") {
|
|
||||||
primary = colors.dominant;
|
|
||||||
backup = colors.vibrant;
|
|
||||||
} else if (colors.platform === "ios") {
|
|
||||||
primary = colors.detail;
|
|
||||||
backup = colors.primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the primary color if it's too close to black
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
|
||||||
primary = adjustToNearBlack(primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the text color based on the primary color
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
|
||||||
|
|
||||||
setPrimaryColor({
|
|
||||||
primary,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache the colors in storage
|
|
||||||
if (source.uri && primary) {
|
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
|
||||||
storage.set(`${source.uri}-text`, text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
console.error("Error getting colors", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
|
||||||
|
|
||||||
if (isTv) return;
|
|
||||||
};
|
|
||||||
@@ -81,7 +81,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
private func setupView() {
|
private func setupView() {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
configureAudioSession()
|
|
||||||
|
|
||||||
videoContainer = UIView()
|
videoContainer = UIView()
|
||||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -141,21 +140,26 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try audioSession.setCategory(
|
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||||
.playback,
|
try session.setActive(true)
|
||||||
mode: .moviePlayback,
|
|
||||||
policy: .longFormAudio,
|
|
||||||
options: []
|
|
||||||
)
|
|
||||||
try audioSession.setActive(true)
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to configure audio session: \(error)")
|
print("Failed to configure audio session: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Audio Session & Notifications
|
|
||||||
|
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||||
|
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||||
|
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||||
|
private func tearDownAudioSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||||
|
}
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
@@ -270,6 +274,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
|
configureAudioSession()
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -440,6 +445,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
|
tearDownAudioSession()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,9 +525,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
|
||||||
nowPlayingManager.activateAudioSession()
|
|
||||||
syncNowPlaying(isPlaying: !isPaused())
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert bits to megabits or gigabits
|
|
||||||
*
|
|
||||||
* Return nice looking string
|
|
||||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
|
||||||
if (!bits) return "0MB";
|
|
||||||
|
|
||||||
const megabits = bits / 1000000;
|
|
||||||
|
|
||||||
if (megabits < 1000) {
|
|
||||||
return `${Math.round(megabits)}MB`;
|
|
||||||
}
|
|
||||||
const gigabits = megabits / 1000;
|
|
||||||
return `${gigabits.toFixed(1)}GB`;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseItemKind,
|
|
||||||
CollectionType,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
|
||||||
*
|
|
||||||
* CollectionTypes
|
|
||||||
* readonly Unknown: "unknown";
|
|
||||||
readonly Movies: "movies";
|
|
||||||
readonly Tvshows: "tvshows";
|
|
||||||
readonly Trailers: "trailers";
|
|
||||||
readonly Homevideos: "homevideos";
|
|
||||||
readonly Boxsets: "boxsets";
|
|
||||||
readonly Books: "books";
|
|
||||||
readonly Photos: "photos";
|
|
||||||
readonly Livetv: "livetv";
|
|
||||||
readonly Playlists: "playlists";
|
|
||||||
readonly Folders: "folders";
|
|
||||||
*/
|
|
||||||
export const colletionTypeToItemType = (
|
|
||||||
collectionType?: CollectionType | null,
|
|
||||||
): BaseItemKind | undefined => {
|
|
||||||
if (!collectionType) return undefined;
|
|
||||||
|
|
||||||
switch (collectionType) {
|
|
||||||
case CollectionType.Movies:
|
|
||||||
return BaseItemKind.Movie;
|
|
||||||
case CollectionType.Tvshows:
|
|
||||||
return BaseItemKind.Series;
|
|
||||||
case CollectionType.Homevideos:
|
|
||||||
return BaseItemKind.Video;
|
|
||||||
case CollectionType.Books:
|
|
||||||
return BaseItemKind.Book;
|
|
||||||
case CollectionType.Playlists:
|
|
||||||
return BaseItemKind.Playlist;
|
|
||||||
case CollectionType.Folders:
|
|
||||||
return BaseItemKind.Folder;
|
|
||||||
case CollectionType.Photos:
|
|
||||||
return BaseItemKind.Photo;
|
|
||||||
case CollectionType.Trailers:
|
|
||||||
return BaseItemKind.Trailer;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
|
||||||
index: number;
|
|
||||||
name: string;
|
|
||||||
uri: string;
|
|
||||||
language: string;
|
|
||||||
default: boolean;
|
|
||||||
forced: boolean;
|
|
||||||
autoSelect: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseM3U8ForSubtitles(
|
|
||||||
url: string,
|
|
||||||
): Promise<SubtitleTrack[]> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, { responseType: "text" });
|
|
||||||
const lines = response.data.split(/\r?\n/);
|
|
||||||
const subtitleTracks: SubtitleTrack[] = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
lines.forEach((line: string) => {
|
|
||||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
|
||||||
const attributes = parseAttributes(line);
|
|
||||||
const track: SubtitleTrack = {
|
|
||||||
index: index++,
|
|
||||||
name: attributes.NAME || "",
|
|
||||||
uri: attributes.URI || "",
|
|
||||||
language: attributes.LANGUAGE || "",
|
|
||||||
default: attributes.DEFAULT === "YES",
|
|
||||||
forced: attributes.FORCED === "YES",
|
|
||||||
autoSelect: attributes.AUTOSELECT === "YES",
|
|
||||||
};
|
|
||||||
subtitleTracks.push(track);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return subtitleTracks;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAttributes(line: string): { [key: string]: string } {
|
|
||||||
const attributes: { [key: string]: string } = {};
|
|
||||||
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
|
||||||
|
|
||||||
for (const match of line.matchAll(regex)) {
|
|
||||||
const key = match[1];
|
|
||||||
const value = match[2] ?? match[3]; // quoted or unquoted
|
|
||||||
attributes[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { Settings } from "../../atoms/settings";
|
|
||||||
import { generateDeviceProfile } from "../../profiles/native";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
|
||||||
api: Api | null | undefined;
|
|
||||||
itemId: string | null | undefined;
|
|
||||||
sessionId: string | null | undefined;
|
|
||||||
deviceProfile: Settings["deviceProfile"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a media item as not played for a specific user.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for marking an item as not played
|
|
||||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
|
||||||
*/
|
|
||||||
export const postCapabilities = async ({
|
|
||||||
api,
|
|
||||||
itemId,
|
|
||||||
sessionId,
|
|
||||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
|
||||||
if (!api || !itemId || !sessionId) {
|
|
||||||
throw new Error("Missing parameters for marking item as not played");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const d = api.axiosInstance.post(
|
|
||||||
`${api.basePath}/Sessions/Capabilities/Full`,
|
|
||||||
{
|
|
||||||
playableMediaTypes: ["Audio", "Video"],
|
|
||||||
supportedCommands: [
|
|
||||||
"PlayState",
|
|
||||||
"Play",
|
|
||||||
"ToggleFullscreen",
|
|
||||||
"DisplayMessage",
|
|
||||||
"Mute",
|
|
||||||
"Unmute",
|
|
||||||
"SetVolume",
|
|
||||||
"ToggleMute",
|
|
||||||
],
|
|
||||||
supportsMediaControl: true,
|
|
||||||
id: sessionId,
|
|
||||||
DeviceProfile: generateDeviceProfile(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return d;
|
|
||||||
} catch (_error) {
|
|
||||||
throw new Error("Failed to mark as not played");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
|
||||||
|
|
||||||
interface NextUpParams {
|
|
||||||
itemId?: string | null;
|
|
||||||
userId?: string | null;
|
|
||||||
api?: Api | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the next up episodes for a series or all series for a user.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for fetching next up episodes
|
|
||||||
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
|
||||||
*/
|
|
||||||
export const nextUp = async ({
|
|
||||||
itemId,
|
|
||||||
userId,
|
|
||||||
api,
|
|
||||||
}: NextUpParams): Promise<BaseItemDto[]> => {
|
|
||||||
if (!userId || !api) {
|
|
||||||
console.error("Invalid parameters for nextUp: missing userId or api");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
|
||||||
`${api.basePath}/Shows/NextUp`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
SeriesId: itemId || undefined,
|
|
||||||
UserId: userId,
|
|
||||||
Fields: "MediaSourceCount",
|
|
||||||
},
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data.Items;
|
|
||||||
} catch (_error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves an item by its ID from the API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance.
|
|
||||||
* @param itemId - The ID of the item to retrieve.
|
|
||||||
* @returns The item object or undefined if no item matches the ID.
|
|
||||||
*/
|
|
||||||
export const getItemById = async (
|
|
||||||
api?: Api | null | undefined,
|
|
||||||
itemId?: string | null | undefined,
|
|
||||||
): Promise<BaseItemDto | undefined> => {
|
|
||||||
if (!api || !itemId) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
|
||||||
|
|
||||||
const item = itemData.data;
|
|
||||||
if (!item) {
|
|
||||||
console.error("No items found with the specified ID:", itemId);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve the item:", error);
|
|
||||||
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// seconds to ticks util
|
|
||||||
|
|
||||||
export function secondsToTicks(seconds: number): number {
|
|
||||||
return seconds * 10000000;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user