Compare commits

..

32 Commits

Author SHA1 Message Date
Fredrik Burmester
5358c1e1d5 fix: double tap works indipendant of controls showing 2025-11-17 07:25:20 +01:00
Fredrik Burmester
cd7a7b0e0e feat: double tap to seek 2025-11-16 22:13:35 +01:00
Fredrik Burmester
3c57829360 feat: prefer downloaded file (#1217)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 21:47:51 +01:00
Fredrik Burmester
06349a4319 chore: remove unused files 2025-11-16 21:16:07 +01:00
Fredrik Burmester
55ac9ae9d4 chore: lint
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-16 16:41:15 +01:00
Fredrik Burmester
c8bdcc4df0 feat: add snap scroll to horizontal lists 2025-11-16 16:40:42 +01:00
lostb1t
e7013edd84 refactor: Move media sources preload higher up the tree (#1216) 2025-11-16 14:59:09 +01:00
Fredrik Burmester
991b45de06 fix: season episode scroll list snapping and scrolling 2025-11-16 14:36:31 +01:00
Fredrik Burmester
97fe899cb0 feat: approve jellyserr requests (#1214) 2025-11-16 14:24:20 +01:00
lostb1t
86d7642dca fix: show tech details when avaiable (#1213) 2025-11-16 14:22:05 +01:00
Fredrik Burmester
631a5ef94e chore: update + correct deps 2025-11-16 13:36:22 +01:00
renovate[bot]
8b8b928837 chore(deps): Pin dependencies (#1209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 13:32:26 +01:00
lostb1t
56a3c62ed2 feat: move source and track selection to seperate sheet (#1176)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-11-16 13:31:20 +01:00
Fredrik Burmester
82683407da chore: version 2025-11-16 11:25:17 +01:00
Fredrik Burmester
7b146e30bd fix: disable gestures from top and bottom of screen because of interference with notification shade pull down (#1206)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-11-16 10:49:54 +01:00
Fredrik Burmester
5f48bec0f2 feat: vlc apple integration - pause on other media play + controls (#1211) 2025-11-16 10:49:35 +01:00
Fredrik Burmester
94362169b6 fix: version 2025-11-16 10:25:20 +01:00
Fredrik Burmester
8aefdac50f fix: use setupxcode with correct xcode version 2025-11-16 10:24:44 +01:00
Fredrik Burmester
665a79924a fix: use macos 26 runner 2025-11-16 09:57:24 +01:00
Fredrik Burmester
b9ddcf8404 fix: update to latest macos runner 2025-11-16 09:30:37 +01:00
Fredrik Burmester
64ffc8db8b fix: lint issues 2025-11-16 08:50:01 +01:00
Gauvain
2a61124a0d chore(deps): upgrade dev dependencies and test utilities (#1195)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 20:24:04 +01:00
Fredrik Burmester
36178c2082 fix: apple tv runtime crash
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-15 18:23:25 +01:00
Fredrik Burmester
e1c69a9ec9 fix: boot apple tv (working playing video) 2025-11-15 18:10:16 +01:00
Fredrik Burmester
01110b8d13 fix: clean toast message jellyseerr movie request (#1201) 2025-11-15 17:39:07 +01:00
Fredrik Burmester
21034f5671 fix: remove log 2025-11-15 17:36:25 +01:00
Fredrik Burmester
1439bcee0d fix: update okhttp v5 and fix android download crash issues (#1203) 2025-11-15 11:07:35 +01:00
Fredrik Burmester
9a906e6d39 fix: auto-filling would cause state not to be updated (#1200) 2025-11-15 11:06:23 +01:00
Jaakko Rantamäki
48de7b7c6d feat: Liquid Glass Icon (#1070)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2025-11-15 09:31:58 +01:00
renovate[bot]
85e5c25206 chore(deps): Update github/codeql-action action to v4.31.3 (#1180)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:45 +01:00
renovate[bot]
3dc84818e8 chore(deps): Update actions/dependency-review-action action to v4.8.2 (#1175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:34 +01:00
renovate[bot]
18102a3045 chore(deps): Update dependency react-native-device-info to v15 (#1182)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 20:56:22 +01:00
94 changed files with 2045 additions and 834 deletions

View File

@@ -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:

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}

177
.vscode/settings.json vendored
View File

@@ -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"
}

View File

@@ -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",
@@ -29,16 +29,12 @@
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 84,
"versionCode": 85,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -171,7 +171,7 @@ export default function page() {
contentInsetAdjustmentBehavior='automatic'
>
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col gap-y-4 px-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
@@ -180,7 +180,7 @@ export default function page() {
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col gap-y-2 mt-2'>
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>

View File

@@ -20,7 +20,7 @@ export default function page() {
return (
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 gap-y-4" : "py-16 gap-y-8"} px-4`}
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>

View File

@@ -255,7 +255,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
</View>
{/* Session controls */}
<View className='flex flex-row mt-2 gap-x-4 justify-center'>
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}

View File

@@ -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 ? (
@@ -82,7 +91,7 @@ export default function Page() {
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 gap-x-2'>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id={orderFilterId}
queryKey='log'
@@ -106,7 +115,7 @@ export default function Page() {
/>
</View>
<ScrollView className='pb-4 px-4'>
<View className='flex flex-col gap-y-2'>
<View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
<TouchableOpacity
@@ -146,7 +155,7 @@ export default function Page() {
</Text>
)}
<Collapsible collapsed={!state[log.timestamp]}>
<View className='mt-2 flex flex-col gap-y-2'>
<View className='mt-2 flex flex-col space-y-2'>
<ScrollView className='rounded-xl' style={codeBlockStyle}>
<Text>{JSON.stringify(log.data, null, 2)}</Text>
</ScrollView>

View File

@@ -1,3 +1,4 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -20,7 +21,14 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
const { data: item, isError } = useItemQuery(id, isOffline);
const { data: item, isError } = useItemQuery(id, false, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
// preload media sources in background
useItemQuery(id, false, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -80,7 +88,7 @@ const Page: React.FC = () => {
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row gap-x-1 mb-8'>
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />

View File

@@ -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) {
@@ -239,7 +290,7 @@ const Page: React.FC = () => {
}
>
<View className='flex flex-col'>
<View className='gap-y-4'>
<View className='space-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
@@ -282,7 +333,7 @@ const Page: React.FC = () => {
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row gap-x-2 mt-4'>
<View className='flex flex-row space-x-2 mt-4'>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
@@ -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>
@@ -382,13 +487,13 @@ const Page: React.FC = () => {
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col gap-y-2 items-start'>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}

View File

@@ -26,7 +26,7 @@ export default function page() {
paddingTop: 8,
}}
>
<View className='flex flex-col gap-y-2'>
<View className='flex flex-col space-y-2'>
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}

View File

@@ -105,7 +105,7 @@ const page: React.FC = () => {
/>
}
>
<View className='flex flex-col gap-y-4 my-4'>
<View className='flex flex-col space-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />

View File

@@ -94,7 +94,7 @@ const page: React.FC = () => {
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems

View File

@@ -418,7 +418,7 @@ export default function search() {
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center gap-y-2'>
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => {

View File

@@ -43,6 +43,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
@@ -673,7 +674,30 @@ export default function page() {
);
}, []);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Prepare metadata for iOS native media controls
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -731,6 +755,7 @@ export default function page() {
initOptions,
}}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}

View File

@@ -1,17 +1,19 @@
import { Link, Stack } from "expo-router";
import { StyleSheet, View } from "react-native";
import { Text } from "../components/common/Text";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<View style={styles.container}>
<Text>This screen doesn't exist.</Text>
<ThemedView style={styles.container}>
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}>
<Text>Go to home screen!</Text>
<ThemedText type='link'>Go to home screen!</ThemedText>
</Link>
</View>
</ThemedView>
</>
);
}

View File

@@ -34,7 +34,6 @@ import { storage } from "@/utils/mmkv";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import "@/global.css";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getLocales } from "expo-localization";
import type { EventSubscription } from "expo-modules-core";

View File

@@ -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 */}
@@ -377,7 +400,7 @@ const Login: React.FC = () => {
{api?.basePath ? (
<View className='flex flex-col flex-1 items-center justify-center'>
<View className='px-4 -mt-20 w-full'>
<View className='flex flex-col gap-y-2'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
@@ -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")}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<g id="g10">
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,184 @@
{
"fill": {
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
},
"groups": [
{
"blur-material": 0.3,
"layers": [
{
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer1.svg",
"name": "streamyfin_logo_layer1"
}
],
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 1
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.6
}
},
{
"blend-mode": "normal",
"blur-material": 0.8,
"hidden": false,
"layers": [
{
"blend-mode": "normal",
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.75000,1.00000"
}
}
],
"hidden": false,
"image-name": "streamyfin_logo_layer2.svg",
"name": "streamyfin_logo_layer2",
"opacity": 1,
"position": {
"scale": 1,
"translation-in-points": [0, 0]
}
}
],
"lighting": "individual",
"name": "Group",
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, -0.01613253252572302]
},
"shadow": {
"kind": "layer-color",
"opacity": 0.35
},
"specular": true,
"translucency-specializations": [
{
"value": {
"enabled": true,
"value": 0.5
}
},
{
"appearance": "tinted",
"value": {
"enabled": true,
"value": 0.8
}
}
]
},
{
"blend-mode": "normal",
"blur-material": 0.5,
"layers": [
{
"fill-specializations": [
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.29000,1.00000"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer3.svg",
"name": "streamyfin_logo_layer3",
"opacity": 0.9
}
],
"name": "Group",
"opacity": 0.8,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.7
}
},
{
"blur-material": 0.5,
"hidden": false,
"layers": [
{
"glass": true,
"hidden-specializations": [
{
"value": false
},
{
"appearance": "tinted",
"value": true
}
],
"image-name": "streamyfin_logo_layer4.svg",
"name": "streamyfin_logo_layer4",
"opacity-specializations": [
{
"value": 1
},
{
"appearance": "tinted",
"value": 0
}
]
}
],
"lighting": "combined",
"name": "Group",
"opacity": 0.9,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View File

@@ -2,6 +2,6 @@ module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-worklets/plugin"],
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
};
};

217
bun.lock
View File

@@ -46,19 +46,18 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^5.0.0-preview.2",
"nativewind": "^2.0.11",
"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",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-css": "^3.0.1",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^15.0.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -81,33 +80,31 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "^4.1.17",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"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",
"@tailwindcss/postcss": "^4.1.17",
"@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",
"postcss": "^8.5.6",
"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",
},
},
},
"overrides": {
"lightningcss": "1.30.1",
"expo-constants": "~18.0.10",
"expo-task-manager": "~14.0.8",
},
"packages": {
"@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="],
@@ -136,7 +133,7 @@
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
@@ -580,36 +577,6 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "postcss": "^8.4.41", "tailwindcss": "4.1.17" } }, "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
@@ -720,8 +687,6 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
@@ -748,7 +713,7 @@
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="],
@@ -774,6 +739,8 @@
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="],
@@ -810,10 +777,16 @@
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
@@ -844,16 +817,12 @@
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"colorjs.io": ["colorjs.io@0.6.0-alpha.1", "", {}, "sha512-c/h/8uAmPydQcriRdX8UTAFHj6SpSHFHBA8LvMikvYWAVApPTwg/pyOXNsGmaCBd6L/EeDlRHSNhTtnIFp/qsg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="],
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"comment-json": ["comment-json@4.4.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg=="],
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
"compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
@@ -868,8 +837,6 @@
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
@@ -880,14 +847,22 @@
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
"css-mediaquery": ["css-mediaquery@0.1.2", "", {}, "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
@@ -920,8 +895,12 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -946,8 +925,6 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="],
@@ -1074,6 +1051,8 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1152,7 +1131,7 @@
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-dirs": ["global-dirs@0.1.1", "", { "dependencies": { "ini": "^1.3.4" } }, "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg=="],
@@ -1220,6 +1199,8 @@
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
@@ -1286,7 +1267,7 @@
"jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
@@ -1326,27 +1307,31 @@
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1372,8 +1357,6 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -1448,7 +1431,7 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
"nativewind": ["nativewind@2.0.11", "", { "dependencies": { "@babel/generator": "^7.18.7", "@babel/helper-module-imports": "7.18.6", "@babel/types": "7.19.0", "css-mediaquery": "^0.1.2", "css-to-react-native": "^3.0.0", "micromatch": "^4.0.5", "postcss": "^8.4.12", "postcss-calc": "^8.2.4", "postcss-color-functional-notation": "^4.2.2", "postcss-css-variables": "^0.18.0", "postcss-nested": "^5.0.6", "react-is": "^18.1.0", "use-sync-external-store": "^1.1.0" }, "peerDependencies": { "tailwindcss": "~3" } }, "sha512-qCEXUwKW21RYJ33KRAJl3zXq2bCq82WoI564fI21D/TiqhfmstZOqPN53RF8qK1NDK6PGl56b2xaTxgObEePEg=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
@@ -1482,6 +1465,8 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
@@ -1542,6 +1527,8 @@
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="],
@@ -1554,9 +1541,23 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="],
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
"postcss-color-functional-notation": ["postcss-color-functional-notation@4.2.4", "", { "dependencies": { "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2" } }, "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg=="],
"postcss-css-variables": ["postcss-css-variables@0.18.0", "", { "dependencies": { "balanced-match": "^1.0.0", "escape-string-regexp": "^1.0.3", "extend": "^3.0.1" }, "peerDependencies": { "postcss": "^8.2.6" } }, "sha512-lYS802gHbzn1GI+lXvy9MYIYDuGnl1WB4FTKoqMQqJ3Mab09A7a/1wZvGTkCEZJTM8mSbIyb1mJYn8f0aPye0Q=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
"postcss-nested": ["postcss-nested@5.0.6", "", { "dependencies": { "postcss-selector-parser": "^6.0.6" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
@@ -1604,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=="],
@@ -1620,9 +1621,7 @@
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
"react-native-css": ["react-native-css@3.0.1", "", { "dependencies": { "babel-plugin-react-compiler": "^19.1.0-rc.2", "colorjs.io": "0.6.0-alpha.1", "comment-json": "^4.2.5", "debug": "^4.4.1" }, "peerDependencies": { "@expo/metro-config": ">=54", "lightningcss": ">=1.27.0", "react": ">=19", "react-native": ">=0.81" } }, "sha512-sE/Qp2p+UaV9+W6T+GwsRH5sIynaTfhM2Khn+tN1q1YKYq1STVyAWyC+0i6ac4mFAO/FxQ/a03BDmrrt48qiuQ=="],
"react-native-device-info": ["react-native-device-info@14.1.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw=="],
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
@@ -1680,10 +1679,14 @@
"react-test-renderer": ["react-test-renderer@19.1.1", "", { "dependencies": { "react-is": "^19.1.1", "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-aGRXI+zcBTtg0diHofc7+Vy97nomBs9WHHFY1Csl3iV0x6xucjNYZZAkiVKGiNYUv23ecOex5jE67t8ZzqYObA=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@@ -1842,11 +1845,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tailwindcss-safe-area": ["tailwindcss-safe-area@1.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" } }, "sha512-wuPUeW5BhWNv9yr3OzaGgpqImQG9FBM4mQIQh2C6yjHmOOZsJ3gh5RfNHt+TM16TMtpQs/8k2TWx8yQTFG7Fcw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
"tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
@@ -1872,6 +1871,8 @@
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -2000,8 +2001,16 @@
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
"@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@babel/plugin-transform-async-to-generator/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/plugin-transform-react-jsx/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@expo/cli/getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
"@expo/cli/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
@@ -2110,18 +2119,6 @@
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
@@ -2134,12 +2131,14 @@
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"babel-preset-expo/babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2158,6 +2157,8 @@
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
"fbjs/ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
@@ -2214,6 +2215,10 @@
"metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
"nativewind/@babel/types": ["@babel/types@7.19.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.18.10", "@babel/helper-validator-identifier": "^7.18.6", "to-fast-properties": "^2.0.0" } }, "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA=="],
"nativewind/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"npm-package-arg/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
@@ -2224,6 +2229,10 @@
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2268,6 +2277,8 @@
"sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],

View File

@@ -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" : ""}

View File

@@ -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"),
@@ -353,7 +355,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
keyboardBlurBehavior='restore'
>
<BottomSheetView>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{title}
@@ -365,7 +367,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col gap-y-2 w-full'>
<View className='flex flex-col space-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
@@ -404,7 +406,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
/>
</View>
{selectedOptions?.mediaSource && (
<View className='flex flex-col gap-y-2 items-start'>
<View className='flex flex-col space-y-2 items-start'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {

View File

@@ -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 });
@@ -124,10 +120,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
)}
</View>
) : (
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
@@ -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 gap-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" && (

View File

@@ -15,7 +15,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className='flex flex-col gap-y-1.5 w-full items-start h-32'
className='flex flex-col space-y-1.5 w-full items-start h-32'
{...props}
>
<View className='w-1/3 h-6 bg-neutral-900 rounded' />

View File

@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row gap-x-2'>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
@@ -52,12 +52,12 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
)}
>
<BottomSheetScrollView>
<View className='flex flex-col gap-y-2 p-4 mb-4'>
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row gap-x-2'>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>

View File

@@ -0,0 +1,203 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useItemQuery } from "@/hooks/useItemQuery";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
setSelectedOptions: React.Dispatch<
React.SetStateAction<SelectedOptions | undefined>
>;
colors?: ThemeColors;
}
export const MediaSourceButton: React.FC<Props> = ({
item,
selectedOptions,
setSelectedOptions,
colors,
}: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data: itemWithSources, isLoading } = useItemQuery(
item.Id,
false,
undefined,
[],
);
const effectiveColors = colors || {
primary: "#7c3aed",
text: "#000000",
};
useEffect(() => {
const firstMediaSource = itemWithSources?.MediaSources?.[0];
if (!firstMediaSource) return;
setSelectedOptions((prev) => {
if (!prev) return prev;
return {
...prev,
mediaSource: firstMediaSource,
};
});
}, [itemWithSources, setSelectedOptions]);
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (source.Name) return source.Name;
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
return `Source ${source.Id}`;
}, []);
const audioStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio",
) || [],
[selectedOptions.mediaSource],
);
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
[selectedOptions.mediaSource],
);
const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = [];
// Bitrate group
groups.push({
title: t("item_card.quality"),
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions.bitrate?.value,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
})),
});
// Media Source group (only if multiple sources)
if (
itemWithSources?.MediaSources &&
itemWithSources.MediaSources.length > 1
) {
groups.push({
title: t("item_card.video"),
options: itemWithSources.MediaSources.map((source) => ({
type: "radio" as const,
label: getMediaSourceDisplayName(source),
value: source,
selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})),
});
}
// Audio track group
if (audioStreams.length > 0) {
groups.push({
title: t("item_card.audio"),
options: audioStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.audioIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
),
})),
});
}
// Subtitle track group (with None option)
if (subtitleStreams.length > 0) {
const noneOption = {
type: "radio" as const,
label: t("common.none"),
value: -1,
selected: selectedOptions.subtitleIndex === -1,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
};
const subtitleOptions = subtitleStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.subtitleIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
),
}));
groups.push({
title: t("item_card.subtitles"),
options: [noneOption, ...subtitleOptions],
});
}
return groups;
}, [
itemWithSources,
selectedOptions,
audioStreams,
subtitleStreams,
getMediaSourceDisplayName,
t,
setSelectedOptions,
]);
const trigger = (
<TouchableOpacity
disabled={!item || isLoading}
onPress={() => setOpen(true)}
className='relative'
>
<View
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
className='absolute w-12 h-12 rounded-full'
/>
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
{isLoading ? (
<ActivityIndicator size='small' color={effectiveColors.text} />
) : (
<Ionicons name='list' size={24} color={effectiveColors.text} />
)}
</View>
</TouchableOpacity>
);
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.media_options")}
open={open}
onOpenChange={setOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -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}>

View File

@@ -1,11 +1,12 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } 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,118 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {
@@ -358,9 +473,6 @@ export const PlayButton: React.FC<Props> = ({
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
// if (Platform.OS === "ios")
// return (
@@ -377,7 +489,7 @@ export const PlayButton: React.FC<Props> = ({
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
// <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) -
@@ -414,7 +526,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
@@ -440,7 +552,7 @@ export const PlayButton: React.FC<Props> = ({
}}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
>
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -

View File

@@ -200,7 +200,7 @@ export const PlayButton: React.FC<Props> = ({
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>

View File

@@ -21,7 +21,7 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null;
return (
<View className='flex flex-row items-center mt-2 gap-x-2' {...props}>
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
@@ -79,7 +79,7 @@ export const JellyserrRatings: React.FC<{
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className='flex flex-row flex-wrap gap-x-1'>
<View className='flex flex-row flex-wrap space-x-1'>
{data?.criticsRating && !!data?.criticsScore && (
<Badge
text={`${data.criticsScore}%`}

54
components/ThemedText.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { StyleSheet, Text, type TextProps } from "react-native";
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
};
export function ThemedText({
style,
type = "default",
...rest
}: ThemedTextProps) {
return (
<Text
style={[
{ color: "white" },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
});

15
components/ThemedView.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { View, type ViewProps } from "react-native";
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({
style,
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
return <View style={[{ backgroundColor: "black" }, style]} {...otherProps} />;
}

View File

@@ -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={`

View File

@@ -1,16 +1,20 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text({ className, ...props }: TextProps) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
<RNText allowFontScaling={false} style={{ color: "white" }} {...props} />
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
return (
<RNText
allowFontScaling={false}
className={`text-white ${className}`}
{...props}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -51,7 +51,7 @@ export const FilterButton = <T,>({
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center gap-x-1
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"

View File

@@ -97,20 +97,19 @@ export const Home = () => {
}
navigation.setOptions({
headerLeft: () => (
<View className='flex flex-row items-center ml-1.5'>
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
@@ -471,7 +470,8 @@ export const Home = () => {
}}
>
<View
className={`flex flex-col gap-y-4 ${Platform.OS === "android" ? "pt-4" : ""}`}
className='flex flex-col space-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {

View File

@@ -475,7 +475,7 @@ export const HomeWithCarousel = () => {
paddingTop: 0,
}}
>
<View className='flex flex-col gap-y-4'>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {
return (

View File

@@ -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;
@@ -84,9 +90,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return (
<View {...props}>
<View className='px-4 mb-2'>
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
</View>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
@@ -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) => (

View File

@@ -154,7 +154,7 @@ const DetailFacts: React.FC<
<Facts
title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className='flex flex-row gap-x-2 items-center'>
<View key={idx} className='flex flex-row space-x-2 items-center'>
{r.type === 3 ? (
// Theatrical
<Ionicons name='ticket' size={16} color='white' />
@@ -189,7 +189,7 @@ const DetailFacts: React.FC<
<Facts
title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className='flex flex-row items-center gap-x-2'>
<View key={idx} className='flex flex-row items-center space-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} />
<Text>{n.name}</Text>
</View>

View File

@@ -118,7 +118,7 @@ const ParallaxSlideShow = <T,>({
}
logo={logo}
>
<View className='flex flex-col gap-y-4 px-4'>
<View className='flex flex-col space-y-4 px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
</View>

View File

@@ -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]);
@@ -302,7 +305,7 @@ const RequestModal = forwardRef<
stackBehavior='push'
>
<BottomSheetView>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.advanced")}
@@ -311,7 +314,7 @@ const RequestModal = forwardRef<
<Text className='text-neutral-300'>{seasonTitle}</Text>
)}
</View>
<View className='flex flex-col gap-y-2'>
<View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && (
<>
<View className='flex flex-col'>

View File

@@ -31,7 +31,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
if (!hasSliders) return null;
return (
<View className='flex flex-col gap-y-4 mb-8'>
<View className='flex flex-col space-y-4 mb-8'>
{sortedSliders.map((slide) => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:

View File

@@ -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>
);

View File

@@ -83,7 +83,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
// Android UI
return (
<View className='flex flex-row justify-end items-center gap-x-1'>
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'

View File

@@ -32,7 +32,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
onPress={() =>
item?.SeriesId && router.push(`/series/${item.SeriesId}`)
}
className='flex flex-col gap-y-2 w-28'
className='flex flex-col space-y-2 w-28'
>
<Poster
id={item?.Id}

View File

@@ -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'
/>
);
};

View File

@@ -128,7 +128,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
}}
/>
{episodes?.length ? (
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
<DownloadItems
title={t("item_card.download.download_season")}
className='ml-2'

View File

@@ -17,7 +17,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
const disabled = useMemo(
() =>
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
pluginSettings?.enableDoubleTapSkip?.locked === true &&
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
[pluginSettings],
@@ -35,13 +35,13 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
subtitle={t(
"home.settings.gesture_controls.horizontal_swipe_skip_description",
)}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
disabled={pluginSettings?.enableDoubleTapSkip?.locked}
>
<Switch
value={settings.enableHorizontalSwipeSkip}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
onValueChange={(enableHorizontalSwipeSkip) =>
updateSettings({ enableHorizontalSwipeSkip })
value={settings.enableDoubleTapSkip}
disabled={pluginSettings?.enableDoubleTapSkip?.locked}
onValueChange={(enableDoubleTapSkip) =>
updateSettings({ enableDoubleTapSkip })
}
/>
</ListItem>

View File

@@ -105,14 +105,14 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
android_keyboardInputMode='adjustResize'
>
<BottomSheetView>
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("home.settings.quick_connect.quick_connect_title")}
</Text>
</View>
<View className='flex flex-col gap-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full gap-y-4'>
<View className='flex flex-col space-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'>
<Text className='text-neutral-400 text-center'>
{t(
"home.settings.quick_connect.enter_the_quick_connect_code",

View File

@@ -130,7 +130,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
</View>
<View className='flex flex-row gap-x-2 shrink-0'>
<View className='flex flex-row space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}

View File

@@ -145,7 +145,7 @@ export const GestureOverlay = ({
});
const handleSkipForward = useCallback(() => {
if (!settings.enableHorizontalSwipeSkip) return;
if (!settings.enableDoubleTapSkip) return;
lightHaptic();
// Defer all actions to avoid useInsertionEffect warning
requestAnimationFrame(() => {
@@ -153,7 +153,7 @@ export const GestureOverlay = ({
showFeedback("play-forward", `+${settings.forwardSkipTime}s`);
});
}, [
settings.enableHorizontalSwipeSkip,
settings.enableDoubleTapSkip,
settings.forwardSkipTime,
lightHaptic,
onSkipForward,
@@ -161,7 +161,7 @@ export const GestureOverlay = ({
]);
const handleSkipBackward = useCallback(() => {
if (!settings.enableHorizontalSwipeSkip) return;
if (!settings.enableDoubleTapSkip) return;
lightHaptic();
// Defer all actions to avoid useInsertionEffect warning
requestAnimationFrame(() => {
@@ -169,7 +169,7 @@ export const GestureOverlay = ({
showFeedback("play-back", `-${settings.rewindSkipTime}s`);
});
}, [
settings.enableHorizontalSwipeSkip,
settings.enableDoubleTapSkip,
settings.rewindSkipTime,
lightHaptic,
onSkipBackward,
@@ -237,8 +237,8 @@ export const GestureOverlay = ({
const { handleTouchStart, handleTouchMove, handleTouchEnd } =
useGestureDetection({
onSwipeLeft: handleSkipBackward,
onSwipeRight: handleSkipForward,
onDoubleTapLeft: handleSkipBackward,
onDoubleTapRight: handleSkipForward,
onVerticalDragStart: handleVerticalDragStart,
onVerticalDragMove: handleVerticalDragMove,
onVerticalDragEnd: handleVerticalDragEnd,
@@ -247,29 +247,30 @@ export const GestureOverlay = ({
screenHeight,
});
// If controls are visible, act like the old tap overlay
if (showControls) {
return (
<Pressable
onPress={onToggleControls}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.75,
}}
/>
);
}
// Background overlay when controls are visible
const controlsOverlay = showControls && (
<Pressable
onPress={onToggleControls}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.75,
}}
/>
);
return (
<>
{/* Gesture detection area */}
{/* Controls overlay when visible */}
{controlsOverlay}
{/* Gesture detection area - always present */}
<Pressable
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}

View File

@@ -127,7 +127,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
)}
</View>
<View className='flex flex-row items-center gap-x-2'>
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV &&
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
Platform.OS === "android") && (

View File

@@ -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

View File

@@ -130,7 +130,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
useEffect(() => {
const fetchTracks = async () => {
if (getSubtitleTracks) {
let subtitleData = await getSubtitleTracks();
let subtitleData: TrackInfo[] | null = null;
try {
subtitleData = await getSubtitleTracks();
} catch (error) {
console.log("[VideoContext] Failed to get subtitle tracks:", error);
return;
}
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
if (
mediaSource?.TranscodingUrl &&
@@ -179,7 +185,13 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
const audioData = await getAudioTracks();
let audioData: TrackInfo[] | null = null;
try {
audioData = await getAudioTracks();
} catch (error) {
console.log("[VideoContext] Failed to get audio tracks:", error);
return;
}
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => {

View File

@@ -4,8 +4,8 @@ import type { GestureResponderEvent } from "react-native";
export interface SwipeGestureOptions {
minDistance?: number;
maxDuration?: number;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onDoubleTapLeft?: () => void;
onDoubleTapRight?: () => void;
onVerticalDragStart?: (side: "left" | "right", initialY: number) => void;
onVerticalDragMove?: (
side: "left" | "right",
@@ -21,13 +21,14 @@ export interface SwipeGestureOptions {
export const useGestureDetection = ({
minDistance = 50,
maxDuration = 800,
onSwipeLeft,
onSwipeRight,
onDoubleTapLeft,
onDoubleTapRight,
onVerticalDragStart,
onVerticalDragMove,
onVerticalDragEnd,
onTap,
screenWidth = 400,
screenHeight = 800,
}: SwipeGestureOptions = {}) => {
const touchStartTime = useRef(0);
const touchStartPosition = useRef({ x: 0, y: 0 });
@@ -36,25 +37,52 @@ 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";
}, []);
// Double tap detection refs
const lastTapTime = useRef(0);
const lastTapPosition = useRef({ x: 0, y: 0 });
const doubleTapTimeWindow = 300; // 300ms window for double tap
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,
@@ -79,9 +107,6 @@ export const useGestureDetection = ({
isDragging.current = true;
dragSide.current = side;
onVerticalDragStart?.(side, touchStartPosition.current.y);
} else if (absX > absY && absX > 10) {
// Horizontal gesture - mark for discrete swipe
gestureType.current = "horizontal";
}
}
@@ -106,6 +131,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,
@@ -115,8 +146,8 @@ export const useGestureDetection = ({
const touchDuration = touchEndTime - touchStartTime.current;
const deltaX = touchEndPosition.x - touchStartPosition.current.x;
const deltaY = touchEndPosition.y - touchStartPosition.current.y;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
const _absX = Math.abs(deltaX);
const _absY = Math.abs(deltaY);
const totalDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// End vertical drag if we were dragging
@@ -140,25 +171,43 @@ export const useGestureDetection = ({
return;
}
// Handle discrete horizontal swipes (for skip) only if it was marked as horizontal
// Check if it's a tap (short duration and small movement)
if (
gestureType.current === "horizontal" &&
hasMovedEnough.current &&
absX > absY &&
totalDistance > minDistance
) {
if (deltaX > 0) {
onSwipeRight?.();
} else {
onSwipeLeft?.();
}
} else if (
!hasMovedEnough.current &&
touchDuration < 300 &&
totalDistance < 10
) {
// It's a tap - short duration and small movement
onTap?.();
const currentTime = Date.now();
const tapX = touchEndPosition.x;
const tapY = touchEndPosition.y;
// Check for double tap
const timeSinceLastTap = currentTime - lastTapTime.current;
const distanceFromLastTap = Math.sqrt(
(tapX - lastTapPosition.current.x) ** 2 +
(tapY - lastTapPosition.current.y) ** 2,
);
if (
timeSinceLastTap <= doubleTapTimeWindow &&
distanceFromLastTap < 50
) {
// It's a double tap
const isLeftSide = tapX < screenWidth / 2;
if (isLeftSide) {
onDoubleTapLeft?.();
} else {
onDoubleTapRight?.();
}
// Reset last tap to prevent triple tap
lastTapTime.current = 0;
lastTapPosition.current = { x: 0, y: 0 };
} else {
// It's a single tap - execute immediately
onTap?.();
lastTapTime.current = currentTime;
lastTapPosition.current = { x: tapX, y: tapY };
}
}
hasMovedEnough.current = false;
@@ -167,10 +216,12 @@ export const useGestureDetection = ({
[
maxDuration,
minDistance,
onSwipeLeft,
onSwipeRight,
onDoubleTapLeft,
onDoubleTapRight,
onVerticalDragEnd,
onTap,
doubleTapTimeWindow,
screenWidth,
],
);

View File

@@ -19,10 +19,14 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
try {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
} catch (error) {
console.log("[VideoDebugInfo] Failed to fetch tracks:", error);
}
}
};
@@ -60,8 +64,24 @@ export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
className='mt-2.5 bg-blue-500 p-2 rounded'
onPress={() => {
if (playerRef.current) {
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
playerRef.current
.getAudioTracks()
.then(setAudioTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get audio tracks:",
err,
);
});
playerRef.current
.getSubtitleTracks()
.then(setSubtitleTracks)
.catch((err) => {
console.log(
"[VideoDebugInfo] Failed to get subtitle tracks:",
err,
);
});
}
}}
>

View File

@@ -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"

View File

@@ -1,5 +0,0 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";

View File

@@ -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";

View File

@@ -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";

View File

@@ -43,37 +43,14 @@ 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,
});
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
setShowSkipButton(false);
}
}
@@ -82,10 +59,6 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
console.log(
`[INTRO_SKIPPER] Skipping intro to:`,
introTimestamps.endTime,
);
lightHapticFeedback();
wrappedSeek(introTimestamps.endTime);
setTimeout(() => {
@@ -96,7 +69,5 @@ export const useIntroSkipper = (
}
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
return { showSkipButton, skipIntro };
};

View File

@@ -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,

View File

@@ -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],
);

View File

@@ -1,12 +1,19 @@
import type { OrientationChangeEvent } from "expo-screen-orientation";
import { 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,52 @@ 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 = async (
lock: (typeof OrientationLock)[keyof typeof OrientationLock],
) => {
if (Platform.isTV) return;
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
await ScreenOrientation.unlockAsync();
if (lock === OrientationLock.DEFAULT) {
await unlockAsync();
} else {
await ScreenOrientation.lockAsync(lock);
await lockAsync(lock);
}
};
const unlockOrientation = async () => {
const unlockOrientationFn = async () => {
if (Platform.isTV) return;
await ScreenOrientation.unlockAsync();
await unlockAsync();
};
return { orientation, setOrientation, lockOrientation, unlockOrientation };
return {
orientation,
setOrientation,
lockOrientation,
unlockOrientation: unlockOrientationFn,
};
};

View File

@@ -1,6 +1,5 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
@@ -26,4 +25,4 @@ if (process.env?.EXPO_TV === "1") {
// config.resolver.unstable_enablePackageExports = false;
module.exports = withNativewind(config);
module.exports = config;

View File

@@ -59,6 +59,13 @@ export type ChapterInfo = {
duration: number;
};
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type VlcPlayerViewProps = {
source: VlcPlayerSource;
style?: ViewStyle | ViewStyle[];
@@ -67,6 +74,7 @@ export type VlcPlayerViewProps = {
muted?: boolean;
volume?: number;
videoAspectRatio?: string;
nowPlayingMetadata?: NowPlayingMetadata;
onVideoProgress?: (event: ProgressUpdatePayload) => void;
onVideoStateChange?: (event: PlaybackStatePayload) => void;
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;

View File

@@ -102,6 +102,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
muted,
volume,
videoAspectRatio,
nowPlayingMetadata,
onVideoLoadStart,
onVideoStateChange,
onVideoProgress,
@@ -131,6 +132,7 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
muted={muted}
volume={volume}
videoAspectRatio={videoAspectRatio}
nowPlayingMetadata={nowPlayingMetadata}
onVideoLoadStart={onVideoLoadStart}
onVideoLoadEnd={onVideoLoadEnd}
onVideoStateChange={onVideoStateChange}

View File

@@ -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 {

View File

@@ -16,6 +16,12 @@ public class VlcPlayerModule: Module {
}
}
Prop("nowPlayingMetadata") { (view: VlcPlayerView, metadata: [String: String]?) in
if let metadata = metadata {
view.setNowPlayingMetadata(metadata)
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",

View File

@@ -1,4 +1,6 @@
import ExpoModulesCore
import MediaPlayer
import AVFoundation
#if os(tvOS)
import TVVLCKit
@@ -24,6 +26,9 @@ class VlcPlayerView: ExpoView {
var hasSource = false
var isTranscoding = false
private var initialSeekPerformed: Bool = false
private var nowPlayingMetadata: [String: String]?
private var artworkImage: UIImage?
private var artworkDownloadTask: URLSessionDataTask?
// MARK: - Initialization
@@ -31,6 +36,8 @@ class VlcPlayerView: ExpoView {
super.init(appContext: appContext)
setupView()
setupNotifications()
setupRemoteCommandCenter()
setupAudioSession()
}
// MARK: - Setup
@@ -60,42 +67,205 @@ class VlcPlayerView: ExpoView {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
#if !os(tvOS)
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
#endif
}
private func setupAudioSession() {
#if !os(tvOS)
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
try audioSession.setActive(true)
print("Audio session configured for media controls")
} catch {
print("Failed to setup audio session: \(error)")
}
#endif
}
private func setupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Play command
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// Pause command
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// Toggle play/pause command
commandCenter.togglePlayPauseCommand.isEnabled = true
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
if player.isPlaying {
self.pause()
} else {
self.play()
}
return .success
}
// Seek forward command
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [15]
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(currentTime + Int32(skipInterval * 1000))
return .success
}
// Seek backward command
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [15]
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let self = self, let player = self.mediaPlayer else {
return .commandFailed
}
let skipInterval = (event as? MPSkipIntervalCommandEvent)?.interval ?? 15
let currentTime = player.time.intValue
self.seekTo(max(0, currentTime - Int32(skipInterval * 1000)))
return .success
}
// Change playback position command (scrubbing)
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self = self,
let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
let positionTime = event.positionTime
self.seekTo(Int32(positionTime * 1000))
return .success
}
print("Remote command center configured")
#endif
}
private func cleanupRemoteCommandCenter() {
#if !os(tvOS)
let commandCenter = MPRemoteCommandCenter.shared()
// Remove all command targets to prevent memory leaks
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
// Disable commands
commandCenter.playCommand.isEnabled = false
commandCenter.pauseCommand.isEnabled = false
commandCenter.togglePlayPauseCommand.isEnabled = false
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.isEnabled = false
commandCenter.changePlaybackPositionCommand.isEnabled = false
print("Remote command center cleaned up")
#endif
}
// MARK: - Public Methods
func startPictureInPicture() {}
@objc func play() {
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
DispatchQueue.main.async {
self.mediaPlayer?.play()
self.isPaused = false
self.updateNowPlayingInfo()
print("Play")
}
}
@objc func pause() {
self.mediaPlayer?.pause()
self.isPaused = true
DispatchQueue.main.async {
self.mediaPlayer?.pause()
self.isPaused = true
self.updateNowPlayingInfo()
}
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
#if !os(tvOS)
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
#endif
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
DispatchQueue.main.async {
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)
let wasPlaying = player.isPlaying
if wasPlaying {
self.play()
player.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
player.play()
}
self.updatePlayerState()
self.updateNowPlayingInfo()
} else {
print("Error: Unable to retrieve video duration")
}
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
}
}
@@ -263,6 +433,55 @@ class VlcPlayerView: ExpoView {
}
}
@objc func setNowPlayingMetadata(_ metadata: [String: String]) {
// Cancel any existing artwork download to prevent race conditions
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
self.nowPlayingMetadata = metadata
print("[NowPlaying] Metadata received: \(metadata)")
// Load artwork asynchronously if provided
if let artworkUri = metadata["artworkUri"], let url = URL(string: artworkUri) {
print("[NowPlaying] Loading artwork from: \(artworkUri)")
artworkDownloadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error as NSError?, error.code == NSURLErrorCancelled {
print("[NowPlaying] Artwork download cancelled")
return
}
if let error = error {
print("[NowPlaying] Artwork loading error: \(error)")
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else if let data = data, let image = UIImage(data: data) {
print("[NowPlaying] Artwork loaded successfully, size: \(image.size)")
self.artworkImage = image
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
} else {
print("[NowPlaying] Failed to create image from data")
// Update Now Playing info without artwork on failure
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
artworkDownloadTask?.resume()
} else {
// No artwork URI provided - update immediately
print("[NowPlaying] No artwork URI provided")
artworkImage = nil
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
@@ -294,6 +513,27 @@ class VlcPlayerView: ExpoView {
// Stop the media player
mediaPlayer?.stop()
// Cancel any in-flight artwork downloads
artworkDownloadTask?.cancel()
artworkDownloadTask = nil
artworkImage = nil
// Cleanup remote command center targets
cleanupRemoteCommandCenter()
#if !os(tvOS)
// Deactivate audio session to allow other apps to use audio
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("Audio session deactivated")
} catch {
print("Failed to deactivate audio session: \(error)")
}
// Clear Now Playing info
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
#endif
// Remove observer
NotificationCenter.default.removeObserver(self)
@@ -327,6 +567,60 @@ class VlcPlayerView: ExpoView {
"duration": durationMs,
])
}
// Update Now Playing info to sync elapsed playback time
// iOS needs periodic updates to keep progress indicator in sync
DispatchQueue.main.async {
self.updateNowPlayingInfo()
}
}
private func updateNowPlayingInfo() {
#if !os(tvOS)
guard let player = self.mediaPlayer else { return }
var nowPlayingInfo = [String: Any]()
// Playback rate (0.0 = paused, 1.0 = playing at normal speed)
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? player.rate : 0.0
// Current playback time in seconds
let currentTimeSeconds = Double(player.time.intValue) / 1000.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTimeSeconds
// Total duration in seconds
if let duration = player.media?.length.intValue {
let durationSeconds = Double(duration) / 1000.0
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = durationSeconds
}
// Add metadata if available
if let metadata = self.nowPlayingMetadata {
if let title = metadata["title"] {
nowPlayingInfo[MPMediaItemPropertyTitle] = title
print("[NowPlaying] Setting title: \(title)")
}
if let artist = metadata["artist"] {
nowPlayingInfo[MPMediaItemPropertyArtist] = artist
print("[NowPlaying] Setting artist: \(artist)")
}
if let albumTitle = metadata["albumTitle"] {
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle
print("[NowPlaying] Setting album: \(albumTitle)")
}
}
// Add artwork if available
if let artwork = self.artworkImage {
print("[NowPlaying] Setting artwork with size: \(artwork.size)")
let artworkItem = MPMediaItemArtwork(boundsSize: artwork.size) { _ in
return artwork
}
nowPlayingInfo[MPMediaItemPropertyArtwork] = artworkItem
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
#endif
}
// MARK: - Expo Events

3
nativewind-env.d.ts vendored
View File

@@ -1,3 +1,2 @@
/// <reference types="react-native-css/types" />
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.

View File

@@ -64,19 +64,18 @@
"i18next": "^25.0.0",
"jotai": "^2.12.5",
"lodash": "^4.17.21",
"nativewind": "^5.0.0-preview.2",
"nativewind": "^2.0.11",
"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",
"react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-css": "^3.0.1",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^15.0.0",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.1",
@@ -99,28 +98,25 @@
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0",
"tailwindcss": "^4.1.17",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"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",
"@tailwindcss/postcss": "^4.1.17",
"@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",
"postcss": "^8.5.6",
"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": {
@@ -154,8 +150,5 @@
"resolutions": {
"expo-constants": "~18.0.10",
"expo-task-manager": "~14.0.8"
},
"overrides": {
"lightningcss": "1.30.1"
}
}

View File

@@ -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;

View File

@@ -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 xcodejs 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;

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -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],
);

View File

@@ -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({

View File

@@ -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]);

View File

@@ -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: {},
},
};

10
tailwind.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -89,8 +89,8 @@
},
"gesture_controls": {
"gesture_controls_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
"horizontal_swipe_skip": "النقر المزدوج للتخطي",
"horizontal_swipe_skip_description": "انقر نقرًا مزدوجًا على الجانب الأيسر/الأيمن عندما تكون عناصر التحكم مخفية للتخطي",
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",

View File

@@ -106,8 +106,8 @@
},
"gesture_controls": {
"gesture_controls_title": "Gesture Controls",
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"horizontal_swipe_skip": "Double Tap to Skip",
"horizontal_swipe_skip_description": "Double tap left/right side when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
@@ -337,7 +337,8 @@
"audio": "Audio",
"subtitle": "Subtitle",
"play": "Play",
"none": "None"
"none": "None",
"track": "Track"
},
"search": {
"search": "Search...",
@@ -428,7 +429,12 @@
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back"
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
},
"item_card": {
"next_up": "Next Up",
@@ -444,6 +450,7 @@
"no_similar_items_found": "No Similar Items Found",
"video": "Video",
"more_details": "More Details",
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
@@ -512,6 +519,10 @@
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",
"appearances": "Appearances",
"approve": "Approve",
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
@@ -519,7 +530,11 @@
"issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
}
},
"tabs": {

View File

@@ -94,8 +94,8 @@
},
"gesture_controls": {
"gesture_controls_title": "Gesztusvezérlés",
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
"horizontal_swipe_skip_description": "Ha a vezérlők el vannak rejtve, húzd balra vagy jobbra az ugráshoz.",
"horizontal_swipe_skip": "Dupla Érintés Ugráshoz",
"horizontal_swipe_skip_description": "Dupla érintés bal/jobb oldalon ha a vezérlők el vannak rejtve az ugráshoz.",
"left_side_brightness": "Fényerő a Bal Oldalon",
"left_side_brightness_description": "Húzd felfelé vagy lefelé a bal oldalon a fényerő állításához",
"right_side_volume": "Fényerő a Jobb Oldalon",

View File

@@ -33,8 +33,7 @@
"*.ts",
"*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
"expo-env.d.ts"
],
"exclude": [
"node_modules",

View File

@@ -26,7 +26,7 @@ export type DownloadOption = {
};
export const ScreenOrientationEnum: Record<
ScreenOrientation.OrientationLock,
(typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock],
string
> = {
[ScreenOrientation.OrientationLock.DEFAULT]:
@@ -154,7 +154,7 @@ export type Settings = {
subtitleMode: SubtitlePlaybackMode;
rememberSubtitleSelections: boolean;
showHomeTitles: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock;
defaultVideoOrientation: (typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock];
forwardSkipTime: number;
rewindSkipTime: number;
showCustomMenuLinks: boolean;
@@ -175,7 +175,7 @@ export type Settings = {
vlcOutlineOpacity?: number;
vlcIsBold?: boolean;
// Gesture controls
enableHorizontalSwipeSkip: boolean;
enableDoubleTapSkip: boolean;
enableLeftSideBrightnessSwipe: boolean;
enableRightSideVolumeSwipe: boolean;
usePopularPlugin: boolean;
@@ -239,7 +239,7 @@ export const defaultValues: Settings = {
vlcOutlineOpacity: undefined,
vlcIsBold: undefined,
// Gesture controls
enableHorizontalSwipeSkip: true,
enableDoubleTapSkip: true,
enableLeftSideBrightnessSwipe: true,
enableRightSideVolumeSwipe: true,
usePopularPlugin: true,