Compare commits

..

56 Commits

Author SHA1 Message Date
Gauvain
d2db319593 fix(library): restore filters on new arch, shared sheet, per-library memory & full reset
- present the filter sheet imperatively from the press handler (Reanimated 4 no-op via useEffect)
- host filters in a single shared sheet to stop stacked sheets
- keep the search input responsive on large option lists (useDeferredValue)
- reset (X) also clears sort & order via a shared useFilterReset hook
- per-library filter memory, reset on app close, scroll grid back to top
- memoize useFilterOptions and drop debug logging
2026-07-15 18:51:50 +02:00
Gauvain
2e79815d78 fix(downloads): delete-all confirmation, live storage usage, series poster cache (#1808)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 / 🔍 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 11:19:51 +02:00
Gauvain
43ce50fe70 fix(notifications): drop deprecated handler flags and payload logging (#1805)
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
2026-07-15 10:50:47 +02:00
Gauvain
bd0eef0407 fix(i18n): correct misspelled translation keys (#1804)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ 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 / 🔍 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-15 08:36:11 +02:00
github-actions[bot]
95b5dab293 feat: New Crowdin Translations (#1758)
Some checks failed
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (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 / 🔍 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-15 00:23:30 +02:00
Gauvain
2df160adb7 ci: skip PR title comment when the lint run was cancelled (#1803) 2026-07-14 19:45:42 +02:00
lance chant
8d54d65b63 feat: adding episode count indicator (#1787)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-14 19:04:15 +02:00
Gauvain
95f4d15e98 chore: remove unused TrackSheet and TVSubtitleSheet component (#1780) 2026-07-14 18:23:54 +02:00
Gauvain
77156630d6 ci: run independent workflow steps in parallel (#1788)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-07-07 23:26:52 +02:00
Gauvain
680f930a47 fix(tv): re-negotiate the stream when changing audio track while transcoding (#1791)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-07 11:46:35 +02:00
renovate[bot]
ed8744389b chore(deps): Update dependency xcode to v26.6 (#1793)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-07-07 00:33:41 +02:00
renovate[bot]
b879026745 chore(deps): Update dependency expo-doctor to v1.20.0 (#1792) 2026-07-06 23:57:06 +02:00
Fredrik Burmester
28a75a2b8c fix(tv): "See All" opens library and Back returns to library list (#1782)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-06-30 11:57:51 +02:00
lance chant
aa0eb0a655 fix: changing pip to use surface view (#1778)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-30 09:58:25 +02:00
Gauvain
286a3cad47 refactor: migrate app.config and Expo config plugins to TypeScript (#1718) 2026-06-30 09:03:47 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-29 19:48:32 +02:00
lance chant
cc0007926d fix: fixing some performance issues and mpv upgrade (#1765)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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
🛡️ Trivy Security Scan / 🔎 Filesystem scan (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 (i18n:check) (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
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-28 17:35:00 +02:00
renovate[bot]
9e29305e28 chore(deps): Update dependency lint-staged to v17.0.8 (#1771)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-25 20:27:25 +02:00
renovate[bot]
ae9c05637b chore(deps): Update actions/cache action to v6 (#1770)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-25 13:30:54 +02:00
renovate[bot]
f820bedf6e chore(deps): Update CI dependencies (minor) (#1768) 2026-06-25 11:41:42 +02:00
bbijsterbosch
47c5d61f28 fix: Roadmap link in README (#1524)
Co-authored-by: Gauvain <contact@uruk.dev>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-25 10:45:50 +02:00
lance chant
517bc7bbb5 feat: android tv menu (#1709)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-25 09:08:12 +02:00
Niyazaki
b256e99fc8 fix(search): set typed text color on Android search bar (#1756)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🚦 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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-23 09:11:38 +02:00
Gauvain
e660b98871 fix(mpv): force software decoding on Android emulator (#1752)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
2026-06-19 00:12:40 +02:00
renovate[bot]
ce66f0256e chore(deps): Update dependency @shopify/flash-list to v2.0.3 (#1629) 2026-06-18 21:53:02 +02:00
renovate[bot]
2ec6594462 chore(deps): Update CI dependencies to v24.17.0 (#1745) 2026-06-18 21:19:10 +02:00
renovate[bot]
18f01fa4ab chore(deps): Update actions/checkout action to v7 (#1746)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-18 20:46:54 +02:00
Thomas Frøysa
df56d62acd fix(android): hold wake and wifi locks during downloads (#1546)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: Simon Eklundh <github@oakgrove.site>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 13:26:40 +02:00
lance chant
872d14786e fix: apple top shelf currently cropping images (#1726)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 11:59:21 +02:00
github-actions[bot]
5bf07b4798 feat: New Crowdin Translations (#1699)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-17 11:32:38 +02:00
renovate[bot]
c3dceedad0 chore(deps): Update dependency lint-staged to v17.0.7 (#1607)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-17 10:56:35 +02:00
Gauvain
bf3dc4a366 ci(artifact-comment): always-on dropdown, build ETA, signed/unsigned fix (#1734) 2026-06-17 09:39:43 +02:00
renovate[bot]
3e81291843 chore(deps): Update CI dependencies to v5.3.0 (#1737)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-16 23:28:08 +02:00
renovate[bot]
7703a1c76f chore(deps): Update CI dependencies to v2.16.3 (#1735) 2026-06-16 21:51:59 +02:00
renovate[bot]
7983c68b9f chore(deps): Lock file maintenance (#1706) 2026-06-16 21:00:59 +02:00
Gauvain
335f1efb2c chore(deps): upgrade TypeScript to 6.0.3 and align Expo SDK 56 packages (#1719) 2026-06-16 20:28:02 +02:00
Gauvain
434cb3bd39 ci: ARM Android runners, slimmer APK artifacts, Renovate-pinned tool versions (#1733)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-16 17:12:32 +02:00
Gauvain
7a6daa011d ci(build): drop free-disk-space apt-get warning + surface unsigned tvOS in artifact comment (#1732)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-15 20:32:53 +02:00
Gauvain
149e3b1b17 fix(hooks): correct useMemo dependency arrays (#1626)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🐛 Update Issue Form Versions / 🔢 Populate version dropdown (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-15 01:01:38 +02:00
Gauvain
f00dad02ba ci(issue-form): auto-populate version dropdown from GitHub releases (#1641) 2026-06-15 00:24:44 +02:00
Gauvain
b7ec841118 refactor: remove dead code (#1625)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-14 18:30:50 +02:00
Gauvain
03864b2a9a feat(home): refresh Continue Watching instantly via WebSocket (#1439) 2026-06-14 17:20:27 +02:00
Gauvain
96116e0451 feat(settings): show Actions run number for CI builds, hide store build number (#1711)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏗️ 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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 11:08:07 +02:00
lance chant
938918fa06 fix: android tv issues (#1672)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-11 10:24:11 +02:00
renovate[bot]
a4b6f456f2 chore(deps): Update CI dependencies to v3.1.0 (#1715)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 09:10:19 +02:00
Gauvain
0a2dadffd2 feat(settings): graduated version tracking (build, branch, commit) (#1677)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-06-10 17:19:28 +02:00
Gauvain
6818ea380f fix(renovate): resolve maven lookups, unnest config, gate Expo SDK updates (#1708)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-10 11:22:49 +02:00
lance chant
7cf0a13317 fix: an issue with save account didn't show the modal (#1705)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-10 10:28:18 +02:00
renovate[bot]
168bf2e54e chore(deps): Update github/codeql-action action to v4.36.2 (#1687)
Some checks failed
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ 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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (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 / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n: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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-09 23:25:26 +02:00
renovate[bot]
6f0230c2ca chore(deps): Update expo/expo-github-action digest to eab7a23 (#1685) 2026-06-09 22:37:14 +02:00
renovate[bot]
d12beee529 chore(deps): Update actions/checkout action to v6.0.3 (#1686) 2026-06-09 21:43:22 +02:00
renovate[bot]
02ffac167b chore(deps): Update dependency xcode to v26.5 (#1609)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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 (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-09 15:58:35 +02:00
renovate[bot]
4eb734c99f chore(deps): Update expo monorepo to v1.19.9 (#1606) 2026-06-09 15:05:23 +02:00
Gauvain
b7bae0072f ci(i18n): detect unused & missing translation keys + clean key debt (#1636) 2026-06-09 11:46:42 +02:00
boolemancer
1685571406 fix(downloads): Use mediaSource.Id instead of item.Id in direct download URL (#1666)
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
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (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
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-08 14:59:29 +02:00
Gauvain
36ed7539a2 ci(security): add Trivy filesystem scan to code scanning (#1644) 2026-06-08 14:05:23 +02:00
188 changed files with 5607 additions and 7465 deletions

View File

@@ -75,10 +75,13 @@ body:
id: version
attributes:
label: Streamyfin Version
description: What version of Streamyfin are you using?
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options:
- 0.54.1
- 0.51.0
- 0.47.1
- 0.30.2
- 0.28.0
- Older
- TestFlight/Development build
validations:

View File

@@ -0,0 +1,21 @@
name: Refresh PR build comment
description: >-
Nudge artifact-comment.yml (via workflow_dispatch) so the PR build-status
comment reflects live per-platform progress as each build job finishes.
runs:
using: composite
steps:
# workflow_dispatch fires even when triggered by the GITHUB_TOKEN, and
# artifact-comment's concurrency group collapses simultaneous nudges, so
# this can't spam the comment. Skipped on forks (their read-only token
# cannot dispatch). github.token is used because composite actions cannot
# read the secrets context.
- if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ github.token }}
HEAD_REF: ${{ github.head_ref }}
REPO: ${{ github.repository }}
run: gh workflow run artifact-comment.yml --ref "$HEAD_REF" -R "$REPO"

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management

64
.github/renovate.json vendored
View File

@@ -30,9 +30,17 @@
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+[A-Za-z0-9._-]+:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
},
{
"customType": "regex",
"description": "Track the Bun version pinned in eas.json build profiles (strict JSON can't hold inline annotations)",
"managerFilePatterns": ["/(^|/)eas\\.json$/"],
"matchStrings": ["\"bun\"\\s*:\\s*\"(?<currentValue>[^\"]+)\""],
"datasourceTemplate": "npm",
"depNameTemplate": "bun"
}
],
"customDatasources": {
@@ -44,22 +52,42 @@
]
}
},
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
"matchPackageNames": [
"expo",
"react",
"react-dom",
"react-native",
"react-native-web",
"expo-*",
"@expo/*"
],
"groupName": "Expo SDK",
"dependencyDashboardApproval": true
},
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
"matchDatasources": ["maven"],
"registryUrls": [
"https://dl.google.com/dl/android/maven2/",
"https://repo.maven.apache.org/maven2/"
]
}
]
}

View File

@@ -18,7 +18,7 @@ jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -144,7 +144,7 @@ jobs:
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
console.log(`Found ${buildRuns.length} build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
@@ -184,21 +184,35 @@ jobs:
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// Map our build targets to their job display names. Exact name is
// tried first so a signed target never collides with its
// "(Unsigned)" sibling (whose name contains the signed name).
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)'],
'Android TV': ['🤖 Build Android APK (TV)'],
'iOS': ['🍎 Build iOS IPA (Phone)'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)'],
'tvOS': ['🍎 Build tvOS IPA'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)']
};
// Prefer an exact name match over a substring match so
// '...(Phone)' doesn't swallow '...(Phone - Unsigned)'.
const findJobForTarget = (jobs, jobNames) =>
jobs.find(j => jobNames.some(name => j.name === name)) ||
jobs.find(j => jobNames.some(name => j.name.includes(name)));
// Format a millisecond duration as "Xm Ys".
const fmtDuration = (ms) => {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return `${min}m ${sec}s`;
};
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -229,10 +243,8 @@ jobs:
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
const job = findJobForTarget(jobs.jobs, jobNames);
if (job) {
buildStatuses[platform] = {
name: job.name,
@@ -358,6 +370,43 @@ jobs:
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Pull per-job durations from the latest successful develop build so
// in-progress / queued targets can show a realistic ETA instead of
// an open-ended spinner. Best-effort: any failure just drops the ETA.
let referenceDurations = {};
try {
const { data: devRuns } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build-apps.yml',
branch: 'develop',
status: 'success',
per_page: 1
});
if (devRuns.workflow_runs.length > 0) {
const refRun = devRuns.workflow_runs[0];
const { data: refJobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: refRun.id
});
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = findJobForTarget(refJobs.jobs, jobNames);
if (job && job.conclusion === 'success' && job.started_at && job.completed_at) {
referenceDurations[platform] = new Date(job.completed_at) - new Date(job.started_at);
}
}
console.log(`Reference durations from develop run ${refRun.id}:`,
Object.fromEntries(Object.entries(referenceDurations).map(([k, v]) => [k, fmtDuration(v)])));
} else {
console.log('No successful develop build found for ETA reference');
}
} catch (error) {
console.log('Failed to fetch develop reference durations:', error.message);
}
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
@@ -369,9 +418,9 @@ jobs:
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /^(?!.*unsigned).*ios.*phone.*ipa/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /^(?!.*unsigned).*ios.*tv.*ipa/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
];
@@ -387,11 +436,12 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// tvOS builds are temporarily disabled until feat/tv-interface
// is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
// Signed tvOS stays disabled until EAS has tvOS provisioning
// profiles (app + TopShelf targets); non-interactive builds can't
// create them. Unsigned tvOS builds, so it flows through normally.
if (target.name === 'tvOS') {
status = '💤 Disabled';
downloadLink = '*Disabled until feat/tv-interface is merged*';
downloadLink = '*Disabled — signed tvOS needs EAS provisioning profiles*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
@@ -406,11 +456,9 @@ jobs:
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
durationInfo = ` - ${fmtDuration(durationMs)}`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
@@ -420,10 +468,16 @@ jobs:
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
downloadLink = '*Build in progress...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Building… ~${fmtDuration(ref)} (avg on develop)*`
: '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
downloadLink = '*Waiting to start...*';
const ref = referenceDurations[target.statusKey];
downloadLink = ref
? `*Waiting to start… ~${fmtDuration(ref)} once running (avg on develop)*`
: '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
@@ -444,7 +498,22 @@ jobs:
commentBody += `\n`;
// Show installation instructions if we have any artifacts
// Static rundown of the build optimisations + what each artifact
// installs on. Always shown (even mid-build) so testers know what
// to expect before downloads are ready.
commentBody += `<details>\n`;
commentBody += `<summary>📦 Build details &amp; device compatibility</summary>\n\n`;
commentBody += `These CI builds are trimmed for size and speed. What that means for installing them:\n\n`;
commentBody += `| Artifact | Architectures | Installs on |\n`;
commentBody += `|---|---|---|\n`;
commentBody += `| 🤖 Android Phone APK | \`arm64-v8a\` | Every 64-bit Android phone (all since ~2017). **Not** an x86_64 emulator or a 32-bit device. |\n`;
commentBody += `| 📺 Android TV APK | \`arm64-v8a\` + \`armeabi-v7a\` | Modern boxes **and** older / cheap 32-bit Android TV sticks. No x86_64. |\n`;
commentBody += `| 🍎 iOS / tvOS IPA | \`arm64\` | iPhone / Apple TV (all current devices). |\n\n`;
commentBody += `**Why no x86_64?** That slice only runs on Android emulators / Chromebooks, never a real phone or TV box — dropping it shrinks the APK and speeds up the build. Local \`bun run android\` is unaffected (it still builds x86_64 from \`app.json\`).\n\n`;
commentBody += `**Runners:** Android on \`ubuntu-26.04\`; iOS / tvOS on Apple Silicon (\`macos-26\`). The size/speed win comes from the ABI trim above, not the runner.\n`;
commentBody += `</details>\n\n`;
// Installation instructions only matter once something is downloadable.
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;

View File

@@ -11,77 +11,102 @@ on:
push:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
# CI artifact ships arm64 only (phones; emulators/Chromebooks not a
# sideload target). Overrides app.json buildArchs for this build only,
# so local `bun run android` (x86_64 emulator) is unaffected.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -95,76 +120,94 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- parallel:
- name: 🗑️ Free Disk Space
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
with:
tool-cache: false
mandb: true
android: false
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: false
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
# TV artifact keeps armeabi-v7a too: many older/cheap Android TV boxes
# and sticks are still 32-bit ARM. Drops only x86_64. CI build only.
ORG_GRADLE_PROJECT_reactNativeArchitectures: arm64-v8a,armeabi-v7a
run: bun run build:android:local
- name: 📅 Set date tag
@@ -178,34 +221,53 @@ jobs:
android/app/build/outputs/apk/release/*.apk
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
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-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -215,23 +277,12 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: eas build -p ios --local --non-interactive
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -243,34 +294,46 @@ jobs:
path: build-*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -280,12 +343,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
@@ -301,36 +358,57 @@ jobs:
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment
build-ios-tv:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
# Disabled: EAS has no provisioning profiles / distribution cert for the tvOS
# targets (app + StreamyfinTopShelf extension), so non-interactive signed
# builds fail. Set up tvOS credentials in EAS (`eas credentials`), then remove
# the `false &&` prefix below. Unsigned tvOS builds run (see job below).
if: false && (!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-26
name: 🍎 Build tvOS IPA
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -340,23 +418,10 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive
run: eas build -p ios --local --non-interactive --profile ci_tv
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -377,28 +442,37 @@ jobs:
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
@@ -408,12 +482,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
@@ -428,3 +496,6 @@ jobs:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
- name: 🔄 Refresh PR build comment
uses: ./.github/actions/refresh-pr-comment

View File

@@ -13,30 +13,33 @@ concurrency:
jobs:
check-lockfile:
name: 🔍 Check bun.lock and package.json consistency
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 🛡️ Verify lockfile consistency
run: |

View File

@@ -8,11 +8,14 @@ on:
schedule:
- cron: '24 2 * * *'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: 🔎 Analyze with CodeQL
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write
@@ -24,16 +27,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -10,14 +10,14 @@ on:
jobs:
label:
name: 🏷️ Labeling Merge Conflicts
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: ${{ github.repository == 'streamyfin/streamyfin' }}
permissions:
contents: read
pull-requests: write
steps:
- name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -19,16 +19,16 @@ permissions:
jobs:
sync-translations:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_translations: true

View File

@@ -15,21 +15,23 @@ jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
run: bun scripts/detect-duplicate-issue.ts
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

View File

@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
on:
pull_request:
types: [opened, edited, synchronize, reopened]
types: [opened, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
push:
@@ -11,47 +11,19 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -65,30 +37,41 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- parallel:
- name: 🛒 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
bun-version: latest
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
# Re-enabled but non-blocking: surfaces doctor warnings in the logs
# without failing the gate (some checks are known-noisy for this setup).
continue-on-error: true
run: bun expo-doctor
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
@@ -97,24 +80,29 @@ jobs:
- "check"
- "format"
- "typecheck"
- "i18n:check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- parallel:
- name: "📥 Checkout PR code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
bun-version: latest
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: "📦 Install dependencies"
run: bun install --frozen-lockfile

View File

@@ -12,7 +12,7 @@ on:
jobs:
notify:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
@@ -29,7 +29,7 @@ jobs:
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure

81
.github/workflows/pr-title-comment.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: 📝 PR Title Comment
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
# read-only token on forks). workflow_run runs from the base branch with the base
# repo's token. This is comment-only — the required check stays pr-title.yml.
on:
workflow_run:
workflows: ["📝 PR Title"]
types: [completed]
permissions:
contents: read
actions: read # download the artifact from the triggering run
pull-requests: write # post/update/delete the sticky comment
concurrency:
group: pr-title-comment-${{ github.event.workflow_run.id }}
cancel-in-progress: true
jobs:
comment:
# Only run when the lint run actually produced a result artifact: a run
# cancelled by concurrency (rapid pushes to the same PR) completes with
# conclusion "cancelled" before uploading it, and would fail the download.
if: >
github.event.workflow_run.event == 'pull_request' &&
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-26.04
steps:
- name: ⬇️ Download lint result
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh run download "${{ github.event.workflow_run.id }}" \
--repo "${{ github.repository }}" \
--name pr-title-result \
--dir result
- name: 🔎 Read result
id: result
run: |
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
if [ -s result/error ]; then
echo "has_error=true" >> "$GITHUB_OUTPUT"
else
echo "has_error=false" >> "$GITHUB_OUTPUT"
fi
- name: 📥 Load error message
if: steps.result.outputs.has_error == 'true'
run: |
{
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
cat result/error
echo
echo "PR_TITLE_ERR_EOF"
} >> "$GITHUB_ENV"
- name: 💬 Post title error
if: steps.result.outputs.has_error == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ env.LINT_ERROR }}
```
- name: 🧹 Clear title error
if: steps.result.outputs.has_error == 'false'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error
delete: true

42
.github/workflows/pr-title.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: 📝 PR Title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-26.04
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
# off the result (PR number + lint error) as an artifact for it to read.
- if: always()
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
run: |
mkdir -p pr-title-result
printf '%s' "$PR_NUMBER" > pr-title-result/number
printf '%s' "$LINT_ERROR" > pr-title-result/error
- if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-title-result
path: pr-title-result/
retention-days: 1

View File

@@ -13,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Queue successive releases in order instead of dropping the extra pending run.
queue: max
on:
push:
@@ -22,8 +24,9 @@ on:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
environment: production
permissions: {}
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
@@ -31,7 +34,7 @@ jobs:
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
strategy:
@@ -62,38 +65,40 @@ jobs:
artifact_name: streamyfin-android-tv-apk
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- parallel:
- name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials
@@ -176,13 +181,13 @@ jobs:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: write
actions: read # required for `gh run download` to list/fetch this run's artifacts
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
show-progress: false

50
.github/workflows/trivy-scan.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-26.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Trivy's own action caches the vulnerability DB + binary internally
# (cache-trivy-* / trivy-binary-* entries), so no manual ~/.cache/trivy
# step is needed — it only duplicated the cache.
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -1,67 +1,104 @@
name: 🐛 Update Bug Report Template
name: 🐛 Update Issue Form Versions
on:
release:
types: [published] # Run on every published release on any branch
# Only full releases populate the dropdown (no drafts/prereleases).
types: [released]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
# Fixed group so a release event and the weekly cron can't race on the same
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
group: update-issue-form
cancel-in-progress: false
permissions:
contents: read
jobs:
update-bug-report:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-26.04
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- parallel:
- name: 📥 Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# On `release` events GITHUB_SHA is the tagged commit — without this the
# script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
cache: 'npm'
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with:
result-encoding: string
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
- name: 🔢 Populate version dropdown from GitHub releases
id: populate
run: bun scripts/update-issue-form.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
- name: 📬 Create pull request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci/update-issue-form
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version'
commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
# 📦 Pull Request
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [x] Verified that changes behave as expected for all platforms
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [x] No secrets, hardcoded credentials, or private config files are included
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
- name: 🔀 Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
# (GitHub anti-recursion), so the required checks stay "Expected" until a
# maintainer kicks them (close/reopen the PR, or push an empty commit).
# Auto-merge is still worth enabling: once checks run and reviews land,
# the PR merges itself.
run: |
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories
/ios
/android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards
- Use TypeScript for all files (no .js)
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -143,14 +143,6 @@ interface ModalOptions {
}
```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling
The modal uses these default styles (can be overridden via options):

View File

@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin

View File

@@ -1,29 +0,0 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
};

79
app.config.ts Normal file
View File

@@ -0,0 +1,79 @@
// Registers the tsx require hook so the TypeScript config plugins referenced
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
import "tsx/cjs";
import { execFileSync } from "node:child_process";
import type { ConfigContext, ExpoConfig } from "expo/config";
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args: string[]): string | null => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
};
const buildMeta = {
commit:
(
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
process.env.GITHUB_SHA ||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
git(["rev-parse", "HEAD"]) ||
""
).slice(0, 7) || null,
branch:
process.env.EAS_BUILD_GIT_BRANCH ||
process.env.GITHUB_HEAD_REF ||
process.env.GITHUB_REF_NAME ||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
null,
profile:
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};
export default ({ config }: ConfigContext): ExpoConfig => {
if (process.env.EXPO_TV !== "1") {
config.plugins?.push("expo-background-task");
config.plugins?.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins?.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set
const androidConfig: { googleServicesFile?: string } = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
config.extra = { ...config.extra, build: buildMeta };
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
} as ExpoConfig;
};

View File

@@ -71,8 +71,8 @@
],
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
"./plugins/withExcludeMedia3Dash.ts",
"./plugins/withTVUserManagement.ts",
[
"expo-build-properties",
{
@@ -107,6 +107,9 @@
],
"expo-localization",
"expo-asset",
"expo-audio",
"expo-image",
"expo-sharing",
[
"react-native-edge-to-edge",
{
@@ -131,17 +134,17 @@
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
["./plugins/with-runtime-framework-headers.ts"],
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
["./plugins/withAndroidAlertColors.ts"],
["./plugins/withAndroidManifest.ts"],
["./plugins/withTrustLocalCerts.ts"],
["./plugins/withGradleProperties.ts"],
["./plugins/withTVOSAppIcon.ts"],
["./plugins/withTVOSTopShelf.ts"],
["./plugins/withTVXcodeEnv.ts"],
[
"./plugins/withGitPod.js",
"./plugins/withGitPod.ts",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"

View File

@@ -1,13 +1,12 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -22,7 +21,6 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -52,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -61,51 +59,6 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -924,81 +877,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
{t("home.settings.other.select_libraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
{t("home.settings.other.select_libraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -23,6 +23,7 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -134,6 +135,12 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
// Collections open with a clean filter slate: the genre/year/tag atoms are
// global, so without this the previously viewed library's selection bleeds
// in (libraries now keep their own per-library memory).
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
if (!collection) return;
@@ -204,40 +211,39 @@ const page: React.FC = () => {
],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
@@ -326,7 +332,7 @@ const page: React.FC = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton />,
component: <ResetFiltersButton libraryId={collectionId} />,
},
{
key: "genre",
@@ -466,7 +472,6 @@ const page: React.FC = () => {
setSortBy,
sortOrder,
setSortOrder,
isFetching,
],
);
@@ -633,43 +638,45 @@ const page: React.FC = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
<FilterSheetProvider>
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
);
}

View File

@@ -9,14 +9,19 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
BackHandler,
FlatList,
Platform,
ScrollView,
@@ -30,6 +35,7 @@ import {
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { FilterSheetProvider } from "@/components/filters/FilterSheetProvider";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
@@ -39,6 +45,7 @@ import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useFilterReset } from "@/hooks/useFilterReset";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
@@ -50,7 +57,9 @@ import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
getFilterByPreference,
getMultiFilterPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -61,11 +70,12 @@ import {
sortOrderAtom,
sortOrderOptions,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -80,8 +90,9 @@ const Page = () => {
sortBy?: string;
sortOrder?: string;
filterBy?: string;
fromSeeAll?: string;
};
const { libraryId } = searchParams;
const { libraryId, fromSeeAll } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
@@ -102,6 +113,9 @@ const Page = () => {
const [sortOrderPreference, setOrderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
const [genrePreference, setGenrePreference] = useAtom(genrePreferenceAtom);
const [yearPreference, setYearPreference] = useAtom(yearPreferenceAtom);
const [tagPreference, setTagPreference] = useAtom(tagPreferenceAtom);
const { orientation } = useOrientation();
@@ -112,6 +126,22 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
// When this library detail was opened from the home "See All" button, its
// libraries stack is just [detail], so the default TV Back would exit to home.
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
// route to the library list instead, so the user can switch libraries. Normal
// entries from the list keep their native pop-to-list behavior.
useFocusEffect(
useCallback(() => {
if (!Platform.isTV || fromSeeAll !== "true") return;
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
router.replace("/(auth)/(tabs)/(libraries)");
return true;
});
return () => sub.remove();
}, [fromSeeAll, router]),
);
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
@@ -183,6 +213,13 @@ const Page = () => {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
}
// Genres / years / tags: per-library saved preference (no URL params), so
// switching libraries restores each library's own selection instead of
// bleeding the previous one.
setSelectedGenres(getMultiFilterPreference(libraryId, genrePreference));
setSelectedYears(getMultiFilterPreference(libraryId, yearPreference));
setSelectedTags(getMultiFilterPreference(libraryId, tagPreference));
}, [
libraryId,
sortOrderPreference,
@@ -191,6 +228,12 @@ const Page = () => {
_setSortBy,
filterByPreference,
_setFilterBy,
genrePreference,
yearPreference,
tagPreference,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
@@ -235,6 +278,32 @@ const Page = () => {
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
);
// Genres / years / tags: save the per-library memory then update the active
// atom (mirrors setSortBy; avoids a save-effect that would corrupt on switch).
const setGenres = useCallback(
(genres: string[]) => {
setGenrePreference({ ...genrePreference, [libraryId]: genres });
setSelectedGenres(genres);
},
[libraryId, genrePreference, setGenrePreference, setSelectedGenres],
);
const setYears = useCallback(
(years: string[]) => {
setYearPreference({ ...yearPreference, [libraryId]: years });
setSelectedYears(years);
},
[libraryId, yearPreference, setYearPreference, setSelectedYears],
);
const setTags = useCallback(
(tags: string[]) => {
setTagPreference({ ...tagPreference, [libraryId]: tags });
setSelectedTags(tags);
},
[libraryId, tagPreference, setTagPreference, setSelectedTags],
);
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
@@ -269,6 +338,23 @@ const Page = () => {
});
}, [library]);
// If this See-All detail was deep-linked on top of the libraries index, collapse
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
// which the native bottom tab reliably auto-pops back to the index (the detail
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
const didCollapseRef = useRef(false);
useEffect(() => {
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
return;
const state = navigation.getState();
if (state?.routes && state.routes.length > 1) {
didCollapseRef.current = true;
const top = state.routes[state.routes.length - 1];
navigation.reset({ index: 0, routes: [top] } as any);
}
}, [navigation, fromSeeAll]);
const fetchItems = useCallback(
async ({
pageParam,
@@ -376,6 +462,29 @@ const Page = () => {
);
}, [data]);
const flashListRef = useRef<FlashListRef<BaseItemDto>>(null);
// Jump the grid to the top when the filters/sort change (incl. reset).
const filterSignature = `${selectedGenres}|${selectedYears}|${selectedTags}|${sortBy[0]}|${sortOrder[0]}|${filterBy}`;
const pendingScrollTopRef = useRef(false);
// Instant feedback: pin to the top the moment the filters change, without
// waiting for the new fetch — and flag a re-pin for once it settles.
useEffect(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
pendingScrollTopRef.current = true;
}, [filterSignature]);
// Safety net: FlashList can restore the previous offset as the filtered list
// grows, so re-pin once the fetch settles. Pagination keeps the same
// signature, so it never re-pins.
useEffect(() => {
if (pendingScrollTopRef.current && !isFetching) {
pendingScrollTopRef.current = false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
}, [isFetching, flatData]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
@@ -491,7 +600,6 @@ const Page = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
const ListHeaderComponent = useCallback(
() => (
<FlatList
@@ -506,7 +614,7 @@ const Page = () => {
data={[
{
key: "reset",
component: <ResetFiltersButton />,
component: <ResetFiltersButton libraryId={libraryId} />,
},
{
key: "genre",
@@ -525,7 +633,7 @@ const Page = () => {
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
set={setGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
@@ -552,7 +660,7 @@ const Page = () => {
});
return response.data.Years || [];
}}
set={setSelectedYears}
set={setYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
@@ -577,7 +685,7 @@ const Page = () => {
});
return response.data.Tags || [];
}}
set={setSelectedTags}
set={setTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
@@ -657,35 +765,23 @@ const Page = () => {
api,
user?.Id,
selectedGenres,
setSelectedGenres,
setGenres,
selectedYears,
setSelectedYears,
setYears,
selectedTags,
setSelectedTags,
setTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// Filter bar reset + visibility, shared with the mobile ResetFiltersButton so
// sort/order can't be forgotten on one path (it used to be reset on neither).
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
@@ -779,15 +875,15 @@ const Page = () => {
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
setGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
setGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
setGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
@@ -795,15 +891,15 @@ const Page = () => {
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
setYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
setYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
setYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
}, [showOptions, t, tvYearFilterOptions, selectedYears, setYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
@@ -811,15 +907,15 @@ const Page = () => {
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
setTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
setTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
setTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
}, [showOptions, t, tvTagFilterOptions, selectedTags, setTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
@@ -867,42 +963,45 @@ const Page = () => {
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
<FilterSheetProvider>
<FlashList
ref={flashListRef}
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</FilterSheetProvider>
);
}

View File

@@ -7,12 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
@@ -25,13 +20,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
@@ -52,10 +41,7 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -120,44 +106,8 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
// Alert when seerr server is configured but user hasn't connected (only when focused)
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
t(
"jellyseerr.connect_in_settings",
"Jellyseerr is available. Connect in Settings to enable request features.",
),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate jellyseerr session when switching to Discover
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired", "Session expired"),
t(
"jellyseerr.session_expired_connect_again",
"Your Jellyseerr session has expired. Please reconnect in Settings.",
),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
@@ -355,6 +305,8 @@ export default function SearchPage() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: color of the user-typed text (was dark and unreadable on the dark header)
textColor: "#fff",
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",

View File

@@ -3,16 +3,24 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import { Stack, useSegments, withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
import { TVNavBar } from "@/components/tv/TVNavBar";
import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import useRouter from "@/hooks/useAppRouter";
import {
isTabRoute,
useTVHomeBackHandler,
useTVTabRootBackHandler,
} from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -33,13 +41,108 @@ export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationEventMap
>(Navigator);
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
function TVTabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
const segments = useSegments();
const router = useRouter();
const currentTab = segments.find(isTabRoute);
const lastSegment = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo(
() =>
[
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
: { key: "(custom-links)", label: t("tabs.custom_links") },
{ key: "(settings)", label: t("tabs.settings") },
].filter((tab): tab is TVNavBarTab => tab !== null),
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.showCustomMenuLinks,
t,
],
);
const activeTabKey = currentTab ?? "(home)";
const visibleKeys = useMemo(
() => new Set(tabs.map((tab) => tab.key)),
[tabs],
);
const handleTabChange = useCallback(
(key: string) => {
if (key === currentTab) return;
if (key === "(home)") eventBus.emit("scrollToTop");
if (key === "(search)") eventBus.emit("searchTabPressed");
router.replace(`/(auth)/(tabs)/${key}`);
},
[currentTab, router],
);
const navigateHome = useCallback(() => {
router.replace("/(auth)/(tabs)/(home)");
}, [router]);
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
// If current tab is no longer visible (setting changed), navigate to home
useEffect(() => {
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
router.replace("/(auth)/(tabs)/(home)");
}
}, [visibleKeys, activeTabKey, router]);
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
<Stack
screenOptions={{ headerShown: false, animation: "none" }}
initialRouteName='(home)'
>
<Stack.Screen name='index' redirect />
</Stack>
<TVNavBar
tabs={tabs}
activeTabKey={activeTabKey}
onTabChange={handleTabChange}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
</View>
);
}
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
// Must be called before any conditional return (rules of hooks)
useTVHomeBackHandler();
if (IS_ANDROID_TV) {
return <TVTabLayout />;
}
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />

View File

@@ -456,10 +456,23 @@ export default function DirectPlayerPage() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.pause();
// Synchronously destroy the mpv instance + decoder + surface buffers
// BEFORE the screen unmounts. Otherwise the next screen (or the next
// episode's player) mounts while the old 4K decoder is still alive,
// causing OOM on low-RAM devices. Native stop() is idempotent so the
// later React unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
// Pre-libmpv-1.0 used `stop()`:
// videoRef.current?.stop();
revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
// Release the keep-awake wakelock acquired during playback so it
// doesn't follow us back to the home screen and block the TV
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
// and only released on the "paused" event; without this, navigating
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
@@ -880,6 +893,27 @@ export default function DirectPlayerPage() {
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// A transcoded stream only carries the audio track the server encoded
// into it — switching requires re-negotiating the stream with the new
// index (like the mobile menu's replacePlayer), not an mpv aid change.
if (isTranscoding) {
const queryParams = new URLSearchParams({
itemId: item?.Id ?? "",
audioIndex: String(index),
subtitleIndex: String(currentSubtitleIndex),
mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(),
}).toString();
// Destroy the current mpv instance BEFORE navigating, same rationale as
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
// during the transition, and two simultaneous decoders OOM-kill low-RAM
// devices. Resume is preserved via the playbackPosition param.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
return;
}
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
@@ -891,7 +925,14 @@ export default function DirectPlayerPage() {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
[
stream?.mediaSource,
item?.Id,
currentSubtitleIndex,
bitrateValue,
router,
progress,
],
);
// TV subtitle track change handler
@@ -1105,6 +1146,15 @@ export default function DirectPlayerPage() {
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
// Destroy the current mpv instance BEFORE navigating so the old 4K
// decoder + surface buffers are freed before the new player screen
// mounts. Without this, Expo Router briefly holds two simultaneous
// mpv instances during the transition (~768 MB of surface buffers
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
// devices. Native stop() is idempotent so the subsequent React
// unmount cleanup is still safe.
videoRef.current?.destroy().catch(() => {});
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
@@ -1115,6 +1165,7 @@ export default function DirectPlayerPage() {
bitrateValue,
router,
isPlaybackStopped,
videoRef,
]);
// Apply subtitle settings when video loads
@@ -1267,7 +1318,7 @@ export default function DirectPlayerPage() {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
t("player.an_error_occurred_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
InteractionManager,
ScrollView,
StyleSheet,
TVFocusGuideView,
@@ -75,6 +76,20 @@ export default function TVOptionModal() {
}, [isReady]);
const handleSelect = (value: any) => {
if (modalState?.deferApplyUntilDismissed) {
// onSelect navigates (the transcode audio switch replacing the player);
// a router.replace fired while this modal is the active route would be
// swallowed. Close FIRST, apply after dismissal.
const onSelect = modalState.onSelect;
store.set(tvOptionModalAtom, null);
router.back();
InteractionManager.runAfterInteractions(() => onSelect?.(value));
return;
}
// State-only callers (detail page, library filters, settings): run before
// closing so the re-render happens while the modal is up. Deferring it until
// after dismissal re-renders the page after focus returns and yanks TV
// focus, leaving navigation stuck.
modalState?.onSelect(value);
store.set(tvOptionModalAtom, null);
router.back();

View File

@@ -7,6 +7,7 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
@@ -84,7 +85,8 @@ configureReanimatedLogger({
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
@@ -100,6 +102,22 @@ SplashScreen.setOptions({
fade: true,
});
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
try {
Image.configureCache({
maxMemoryCost: Platform.isTV
? 8 * 1024 * 1024 // ~8 MB on TV
: 128 * 1024 * 1024, // ~128 MB on mobile
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
});
} catch {
// configureCache is a no-op on some platforms/versions; safe to ignore.
}
function useNotificationObserver() {
const router = useRouter();
@@ -333,9 +351,12 @@ function Layout() {
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
// Log only the title — serializing the whole notification touches
// the deprecated dataString getter (deprecation warning) and dumps
// noisy payloads into the console.
console.log(
"Notification received while app running",
notification,
"Notification received while app running:",
notification.request.content.title,
);
},
);

View File

@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

458
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}

View File

@@ -1,203 +0,0 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,8 +1,9 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")}
data={item.MediaSources || []}
values={selected ? [selected] : []}

View File

@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid],
);
const isPresentedRef = useRef(false);
useEffect(() => {
if (visible) {
bottomSheetModalRef.current?.present();
} else {
} else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
}
}, [visible]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
resetState();
onClose();
}

View File

@@ -1,99 +0,0 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
streamType?: string;
title: string;
}
export const TrackSheet: React.FC<Props> = ({
source,
onChange,
selected,
streamType,
title,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source],
);
const selectedSteam = useMemo(
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => {
const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index);
}
}}
/>
</View>
);
};

View File

@@ -1,43 +1,119 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { Platform, View } from "react-native";
import React from "react";
import { Platform, View, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (Platform.isTV) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
const isAggregateType = (item: BaseItemDto) =>
item.Type === "Series" || item.Type === "BoxSet";
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
const tvBadgeBase: ViewStyle = {
position: "absolute",
top: scaleSize(8),
right: scaleSize(8),
height: scaleSize(28),
borderRadius: scaleSize(14),
backgroundColor: "rgba(255,255,255,0.92)",
alignItems: "center",
justifyContent: "center",
};
// Mobile uses raw dp — no scaling.
const mobileBadgeBase: ViewStyle = {
position: "absolute",
top: 4,
right: 4,
height: 20,
borderRadius: 10,
backgroundColor: "#9333ea",
alignItems: "center",
justifyContent: "center",
};
/**
* Renders the unplayed-episode count badge for Series/BoxSet items that still
* have episodes left to watch. Returns null for non-aggregate types, fully
* watched items, or items with no unplayed count, so it is safe to mount
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
* the watched checkmark is drawn natively and only the count needs RN).
*/
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
if (!isAggregateType(item)) return null;
if (item.UserData?.Played) return null;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
if (unplayed <= 0) return null;
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
if (Platform.isTV) {
return (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
style={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Ionicons name='checkmark' size={18} color='black' />
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{label}
</Text>
</View>
);
}
return null;
return (
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
{label}
</Text>
</View>
);
},
);
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
const isAggregate = isAggregateType(item);
const isPlayed = item.UserData?.Played === true;
if (Platform.isTV) {
// Fully watched → white checkmark badge (top-right)
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
return (
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
</View>
);
}
// Series/BoxSet with remaining episodes → count badge
return <UnplayedCountBadge item={item} />;
}
// Mobile: Show purple triangle for unwatched
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
return (
<>
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
{isMovieOrEpisode && item.UserData?.Played === false && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Fully watched Series/BoxSet → small purple checkmark */}
{isAggregate && isPlayed && (
<View style={[mobileBadgeBase, { width: 20 }]}>
<Ionicons name='checkmark' size={13} color='white' />
</View>
)}
{/* Series/BoxSet with remaining episodes → count badge */}
<UnplayedCountBadge item={item} />
</>
);
};

View File

@@ -1,20 +0,0 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -1,28 +0,0 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { showActionSheetWithOptions } = useActionSheet();
const router = useRouter();
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
// instead of freezing the first-rendered series' image (empty deps bug).
const base64Image = useMemo(() => {
return storage.getString(items[0].SeriesId!);
}, []);
const seriesId = items[0]?.SeriesId;
return seriesId ? storage.getString(seriesId) : undefined;
}, [items[0]?.SeriesId]);
const deleteSeries = useCallback(
async () =>

View File

@@ -1,9 +1,11 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
import { useFilterSheet } from "./FilterSheetProvider";
interface FilterButtonProps<T> extends ViewProps {
id: string;
@@ -33,22 +35,63 @@ export const FilterButton = <T,>({
icon = "filter",
...props
}: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false);
// When a FilterSheetProvider is present (library / collections), all buttons
// share one sheet so two can never stack. Outside a provider (e.g. logs,
// discover), fall back to this button's own standalone sheet.
const shared = useFilterSheet();
const { data: filters } = useQuery<T[]>({
const { data: filters, isLoading } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
// Standalone-mode state (unused in shared mode).
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const onButtonPress = useCallback(() => {
if (shared) {
shared.openFilter({
key: `${id}:${queryKey}`,
id,
queryKey,
queryFn,
title,
values: values as unknown[],
set: set as (value: unknown[]) => void,
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
searchFilter: searchFilter as
| ((item: unknown, query: string) => boolean)
| undefined,
disableSearch,
multiple,
});
return;
}
// present() must run from the press handler: from an effect after a state
// update it silently no-ops on the new architecture and the sheet never
// appears.
setOpen(true);
sheetModalRef.current?.present();
}, [
shared,
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch,
multiple,
]);
return (
<>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<TouchableOpacity onPress={onButtonPress}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -85,18 +128,22 @@ export const FilterButton = <T,>({
)}
</View>
</TouchableOpacity>
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
{!shared && (
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
)}
</>
);
};

View File

@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
loading = false,
open,
set,
setOpen,
modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => {
if (!search) return _data;
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], search)) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [search, _data, searchFilter]);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
setData(newData);
}, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
if (!open && wasPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData;
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [search, filteredData, data]);
}, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
[],
);
// Memoized so typing in the search input (urgent render with an unchanged
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
// Match the deep-equality rule used to render the selected
// state below — option objects are recreated across renders,
// so reference checks would re-add an already selected item.
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!isSelected) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
)),
[renderData, values, multiple, set, setOpen, renderItemLabel],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
}}
>
<Text className='font-bold text-2xl'>{title}</Text>
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
if (multiple) {
if (!values.includes(item)) set(values.concat(item));
else set(values.filter((v) => v !== item));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
))}
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button

View File

@@ -0,0 +1,74 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
interface FilterSheetContextType {
openFilter: (config: FilterConfig) => void;
}
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
/**
* Returns the shared-sheet controller, or null when rendered outside a
* FilterSheetProvider — FilterButton then falls back to its own standalone
* sheet (used by screens that don't host a provider, e.g. logs / discover).
*/
export const useFilterSheet = (): FilterSheetContextType | null =>
useContext(FilterSheetContext);
/**
* Hosts the single shared filter sheet for a screen. Every FilterButton under
* it calls openFilter() to show its options in that one sheet — so two sheets
* can never stack regardless of how fast the buttons are tapped. present() runs
* synchronously from the button's press handler (the modal is always mounted).
*/
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const modalRef = useRef<BottomSheetModal | null>(null);
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<FilterConfig | null>(null);
// First-wins guard. With a single shared sheet there is exactly one source of
// truth (this ref) reset on the one close path — so unlike a per-button guard
// it can't get stuck on remounts or multiple instances. A second tap during
// the first sheet's open animation is ignored; the first tapped filter wins.
const openRef = useRef(false);
const openFilter = useCallback((next: FilterConfig) => {
if (openRef.current) return;
openRef.current = true;
setConfig(next);
setOpen(true);
modalRef.current?.present();
}, []);
// Single close path for every dismissal (select / swipe / backdrop) — frees
// the guard reliably.
const closeSheet = useCallback(() => {
openRef.current = false;
setOpen(false);
}, []);
const value = useMemo(() => ({ openFilter }), [openFilter]);
return (
<FilterSheetContext.Provider value={value}>
{children}
<SharedFilterSheet
modalRef={modalRef}
open={open}
setOpen={closeSheet}
config={config}
/>
</FilterSheetContext.Provider>
);
};

View File

@@ -1,38 +1,24 @@
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useFilterReset } from "@/hooks/useFilterReset";
interface Props extends TouchableOpacityProps {}
interface Props extends TouchableOpacityProps {
libraryId: string;
}
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
export const ResetFiltersButton: React.FC<Props> = ({
libraryId,
...props
}) => {
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0 &&
selectedFilters.length === 0
) {
if (!hasActiveFilters) {
return null;
}
return (
<TouchableOpacity
onPress={() => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
setSelectedFilters([]);
}}
onPress={resetAllFilters}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
>

View File

@@ -0,0 +1,286 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
/**
* Config for the filter currently shown by the shared sheet. Generics are erased
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
*/
export interface FilterConfig {
/** Stable identity — changing it remounts the content with fresh state. */
key: string;
id: string;
queryKey: string;
queryFn: (params: any) => Promise<any>;
title: string;
values: any[];
set: (value: any[]) => void;
renderItemLabel: (item: any) => React.ReactNode;
searchFilter?: (item: any, query: string) => boolean;
disableSearch?: boolean;
multiple?: boolean;
}
const LIMIT = 100;
interface SharedFilterSheetProps {
modalRef: React.RefObject<BottomSheetModal | null>;
open: boolean;
setOpen: (open: boolean) => void;
config: FilterConfig | null;
}
/**
* The single shared filter sheet — one BottomSheetModal hosted by
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
* Because only one modal ever exists, rapid taps across buttons can never stack
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
* stable ref (present() can run synchronously from the tapping button); the
* inner content is keyed by the active filter so its pagination/search reset
* cleanly between filters.
*/
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
modalRef,
open,
setOpen,
config,
}) => {
const snapPoints = useMemo(() => ["85%"], []);
const insets = useSafeAreaInsets();
// Opening is imperative (the provider calls present()); this effect only
// closes, and never dismisses a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (!open && wasPresentedRef.current) {
modalRef.current?.dismiss();
}
}, [open, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
},
[setOpen],
);
const requestClose = useCallback(() => setOpen(false), [setOpen]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={modalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
>
<BottomSheetScrollView style={{ flex: 1 }}>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{config && (
<SharedFilterSheetContent
key={config.key}
config={config}
onRequestClose={requestClose}
/>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};
interface SharedFilterSheetContentProps {
config: FilterConfig;
onRequestClose: () => void;
}
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
config,
onRequestClose,
}) => {
const {
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
} = config;
const { t } = useTranslation();
// The options query lives here (deduped with the FilterButton's own query via
// the shared React Query key), so the list stays live after the sheet opens.
const { data: _data, isLoading: loading } = useQuery<any[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
const [data, setData] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
// Filtering on every keystroke blocks the JS thread on large lists; defer the
// value so the keystroke render stays cheap and the list update runs after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState(false);
const filteredData = useMemo(() => {
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
if (data.length > 15) {
setShowSearch(true);
}
}, [data, disableSearch]);
// Loads data in batches of LIMIT from offset (efficient "load more").
useEffect(() => {
if (!_data || _data.length === 0) return;
const newData = [...data];
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
const item = _data[i];
// Option objects are recreated across renders → dedupe by value.
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
}
setData(newData);
}, [offset, _data]);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => onRequestClose(), 250);
} else if (!isSelected) {
set([item]);
setTimeout(() => onRequestClose(), 250);
}
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{ height: StyleSheet.hairlineWidth }}
className='h-1 divide-neutral-700'
/>
</View>
)),
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
);
return (
<>
<Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
className='my-2 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
<View
style={{ borderRadius: 20, overflow: "hidden" }}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
)}
</>
);
};

View File

@@ -140,9 +140,11 @@ export const Home = () => {
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
// decoded ARGB) is too large to pin in the memory cache on every
// focus change. Disk cache is fast enough for a 500ms crossfade.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}

View File

@@ -201,12 +201,18 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const handleSeeAllPress = useCallback(() => {
if (!parentId) return;
// Navigate into the library detail (lives in the libraries tab) sorted by most
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
// the libraries stack so the native tab can't auto-pop it back to the list, and
// (b) intercept Back to route to the library list so the user can switch
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
pathname: "/[libraryId]",
params: {
libraryId: parentId,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
fromSeeAll: "true",
},
} as any);
}, [router, parentId]);
@@ -326,9 +332,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
initialNumToRender={4}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={false}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
@@ -348,11 +354,14 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
// No fixed width: the footer must size to the "See All" card so the
// FlatList's scrollable content extends to fully reveal it. A fixed
// (narrow) width clipped the card at the right edge. Trailing space is
// provided by contentContainerStyle.paddingRight.
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (

View File

@@ -256,8 +256,11 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
let isCancelled = false;
const performCrossfade = async () => {
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
// out of the memory cache avoids bloat when the user cycles through
// hero items quickly.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}
@@ -379,7 +382,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const tvosTopPadding = scaleSize(145);
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -23,6 +22,8 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps {
item: MovieResult | TvResult;
isFirstItem?: boolean;
@@ -33,7 +34,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,8 +50,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -73,7 +71,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[
animatedStyle,
{
width: posterWidth,
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -83,9 +81,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
<View
style={{
width: posterWidth,
width: 210,
aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -142,12 +140,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
>
{title}
</Text>
{year != null && (
{year && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: sizes.gaps.small,
marginTop: 2,
}}
>
{year}
@@ -168,7 +166,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -234,14 +231,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null;
return (
<View style={{ marginBottom: sizes.gaps.section }}>
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{slideTitle}
@@ -252,9 +249,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
onEndReached={() => {

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[library],
[api, library],
);
const itemType = useMemo(() => {

View File

@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
} finally {

View File

@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
} finally {
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
: t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
} finally {
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
t("login.an_unexpected_error_occurred"),
);
}
} finally {
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
const message =
error instanceof Error
? error.message
: t("login.an_unexpected_error_occured");
: t("login.an_unexpected_error_occurred");
Alert.alert(t("login.connection_failed"), message);
goToQRScreen();
});

View File

@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
<View style={styles.buttonContainer}>
<TVSubmitButton
onPress={handleSubmit}
label={t("login.login")}
label={t("login.login_button")}
loading={isLoading}
disabled={!password}
/>

View File

@@ -1,12 +0,0 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
try {
await Image.prefetch(backdropUrl);
await Image.prefetch(backdropUrl, "disk");
} catch {
// Continue even if prefetch fails
}

View File

@@ -1,63 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -1,8 +1,8 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps {
item: BaseItemDto;

View File

@@ -1,48 +0,0 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
<WatchedIndicator item={item} />
</View>
);
};

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -15,21 +14,20 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps {
item: MovieResult | TvResult;
onPress: () => void;
isFirstItem?: boolean;
disabled?: boolean;
}
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item,
onPress,
isFirstItem = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -45,22 +43,18 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem && !disabled}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirstItem}
>
<Animated.View
style={[
animatedStyle,
{
width: posterWidth,
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
@@ -70,9 +64,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
>
<View
style={{
width: posterWidth,
width: 210,
aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
@@ -123,13 +117,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: sizes.gaps.small,
marginTop: 12,
}}
numberOfLines={2}
>
{title}
</Text>
{year != null && (
{year && (
<Text
style={{
fontSize: typography.callout,
@@ -148,16 +142,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
interface TVJellyseerrPersonPosterProps {
item: PersonResult;
onPress: () => void;
disabled?: boolean;
}
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
item,
onPress,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation();
@@ -166,21 +157,13 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View
style={[
animatedStyle,
{
width: avatarSize,
width: 160,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
@@ -191,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
>
<View
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
width: 140,
height: 140,
borderRadius: 70,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0,
@@ -215,11 +198,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center",
}}
>
<Ionicons
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
@@ -228,7 +207,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
marginTop: sizes.gaps.small,
marginTop: 12,
textAlign: "center",
}}
numberOfLines={2}
@@ -244,7 +223,6 @@ interface TVJellyseerrMovieSectionProps {
title: string;
items: MovieResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: MovieResult) => void;
}
@@ -252,22 +230,20 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
title,
items,
isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: sizes.gaps.section }}>
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -278,9 +254,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -288,7 +264,6 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/>
)}
/>
@@ -300,7 +275,6 @@ interface TVJellyseerrTvSectionProps {
title: string;
items: TvResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: TvResult) => void;
}
@@ -308,22 +282,20 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
title,
items,
isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: sizes.gaps.section }}>
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -334,9 +306,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
@@ -344,7 +316,6 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
item={item}
onPress={() => onItemPress(item)}
isFirstItem={isFirstSection && index === 0}
disabled={disabled}
/>
)}
/>
@@ -356,7 +327,6 @@ interface TVJellyseerrPersonSectionProps {
title: string;
items: PersonResult[];
isFirstSection?: boolean;
disabled?: boolean;
onItemPress: (item: PersonResult) => void;
}
@@ -364,22 +334,20 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
title,
items,
isFirstSection: _isFirstSection = false,
disabled = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: sizes.gaps.section }}>
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: sizes.gaps.small,
marginLeft: sizes.padding.scale,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
@@ -390,16 +358,15 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: sizes.padding.scale,
paddingVertical: sizes.padding.scale,
gap: sizes.gaps.item,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
renderItem={({ item }) => (
<TVJellyseerrPersonPoster
item={item}
onPress={() => onItemPress(item)}
disabled={disabled}
/>
)}
/>
@@ -417,7 +384,6 @@ export interface TVJellyseerrSearchResultsProps {
onMoviePress: (item: MovieResult) => void;
onTvPress: (item: TvResult) => void;
onPersonPress: (item: PersonResult) => void;
disabled?: boolean;
}
export const TVJellyseerrSearchResults: React.FC<
@@ -432,10 +398,8 @@ export const TVJellyseerrSearchResults: React.FC<
onMoviePress,
onTvPress,
onPersonPress,
disabled = false,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
if (loading) {
return null;
@@ -446,7 +410,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontSize: 24,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -454,9 +418,7 @@ export const TVJellyseerrSearchResults: React.FC<
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
"{searchQuery}"
</Text>
</View>
@@ -473,21 +435,18 @@ export const TVJellyseerrSearchResults: React.FC<
title={t("search.request_movies")}
items={movieResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={false}
disabled={disabled}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -166,7 +166,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items
const getImageUrl = useMemo(() => {
@@ -271,9 +270,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/>
</View>
)}
@@ -294,7 +290,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
disabled={isSearchFocused}
/>
</View>
)}
@@ -321,7 +316,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
@@ -345,7 +339,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
disabled={isSearchFocused}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}

View File

@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>

View File

@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
successHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.success"),
t("home.settings.quick_connect.quick_connect_autorized"),
t("home.settings.quick_connect.quick_connect_authorized"),
);
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Alert, Platform, View } from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation();
const queryClient = useQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
used: (app.total - app.remaining) / app.total,
};
},
// Keep the bar moving while a download is writing to disk.
refetchInterval: 10 * 1000,
});
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
const onDeleteClicked = () => {
Alert.alert(
t("home.settings.storage.delete_all_downloaded_files_confirm"),
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("common.ok"),
style: "destructive",
onPress: async () => {
try {
await deleteAllFiles();
successHapticFeedback();
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
} finally {
// Reflect the freed space immediately instead of waiting for
// the next poll.
queryClient.invalidateQueries({ queryKey: ["appSize"] });
}
},
},
],
);
};
const calculatePercentage = (value: number, total: number) => {

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const version =
Application?.nativeApplicationVersion ||
Application?.nativeBuildVersion ||
"N/A";
// Graduated build identifier — see utils/version.ts:
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
const { display: version } = getVersionInfo();
return (
<View {...props}>

155
components/tv/TVNavBar.tsx Normal file
View File

@@ -0,0 +1,155 @@
import React from "react";
import {
Animated,
Pressable,
ScrollView,
StyleProp,
View,
ViewStyle,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVPadding } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { scaleSize } from "@/utils/scaleSize";
export interface TVNavBarTab {
key: string;
label: string;
}
export interface TVNavBarProps {
tabs: TVNavBarTab[];
activeTabKey: string;
onTabChange: (key: string) => void;
style?: StyleProp<ViewStyle>;
}
const TVNavBarTabItem: React.FC<{
label: string;
isActive: boolean;
onSelect: () => void;
onLayout: (e: {
nativeEvent: { layout: { x: number; width: number } };
}) => void;
hasTVPreferredFocus: boolean;
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const bg = focused
? "rgba(255, 255, 255, 0.95)"
: isActive
? "rgba(255, 255, 255, 0.15)"
: "transparent";
const textColor = focused
? "#000"
: isActive
? "#fff"
: "rgba(255, 255, 255, 0.7)";
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
onLayout={onLayout}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: bg,
borderRadius: scaleSize(24),
borderWidth: isActive && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.3)",
paddingHorizontal: scaleSize(28),
paddingVertical: scaleSize(14),
},
]}
>
<Text
style={{
fontSize: typography.heading,
color: textColor,
fontWeight: isActive || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVNavBar: React.FC<TVNavBarProps> = ({
tabs,
activeTabKey,
onTabChange,
style,
}) => {
const scrollRef = React.useRef<ScrollView>(null);
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
{},
);
const insets = useSafeAreaInsets();
const handleTabLayout = React.useCallback(
(key: string) =>
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
tabLayouts.current[key] = e.nativeEvent.layout;
},
[],
);
const handleTabChange = React.useCallback(
(key: string) => {
onTabChange(key);
const layout = tabLayouts.current[key];
if (layout && scrollRef.current) {
scrollRef.current.scrollTo({
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
animated: true,
});
}
},
[onTabChange],
);
if (tabs.length === 0) return null;
return (
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
gap: scaleSize(12),
}}
>
{tabs.map((tab) => (
<TVNavBarTabItem
key={tab.key}
label={tab.label}
isActive={tab.key === activeTabKey}
onSelect={() => handleTabChange(tab.key)}
onLayout={handleTabLayout(tab.key)}
hasTVPreferredFocus={tab.key === activeTabKey}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -12,7 +12,10 @@ import {
} from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
{/*
The glass view draws the watched checkmark natively but cannot show
an unplayed-episode count, so render it as an RN overlay on top.
Returns null when not applicable (non-series / fully watched).
*/}
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
</View>
);
}
@@ -448,8 +457,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
<Image
placeholder={{ blurhash }}
key={item.Id}
id={item.Id}
source={{ uri: imageUrl }}
recyclingKey={item.Id}
cachePolicy='memory-disk'
contentFit='cover'
style={{
@@ -459,7 +468,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
<WatchedIndicator item={item} />
{showWatchedIndicator && <WatchedIndicator item={item} />}
<ProgressBar item={item} />
</View>
);

View File

@@ -35,6 +35,8 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
export { TVNavBar } from "./TVNavBar";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";

View File

@@ -564,6 +564,9 @@ export const Controls: FC<Props> = ({
title: t("item_card.audio"),
options: audioOptions,
onSelect: handleAudioChange,
// In-player audio selection navigates (replacePlayer while transcoding);
// apply it after the modal is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
});
controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]);

View File

@@ -1,560 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVLanguageCard,
TVSubtitleResultCard,
TVTabButton,
TVTrackCard,
} from "@/components/tv";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
interface TVSubtitleSheetProps {
visible: boolean;
item: BaseItemDto;
mediaSourceId?: string | null;
subtitleTracks: MediaStream[];
currentSubtitleIndex: number;
onSubtitleIndexChange: (index: number) => void;
onClose: () => void;
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
}
type TabType = "tracks" | "download";
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
mediaSourceId,
subtitleTracks,
currentSubtitleIndex,
onSubtitleIndexChange,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
console.log(
"[TVSubtitleSheet] visible:",
visible,
"tracks:",
subtitleTracks.length,
);
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTabContentReady, setIsTabContentReady] = useState(false);
const firstTrackRef = useRef<View>(null);
const {
hasOpenSubtitlesApiKey,
isSearching,
searchError,
searchResults,
search,
downloadAsync,
reset,
} = useRemoteSubtitles({
itemId: item.Id ?? "",
item,
mediaSourceId,
});
const resetRef = useRef(reset);
resetRef.current = reset;
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (!visible) {
setHasSearchedThisSession(false);
setActiveTab("tracks");
resetRef.current();
setIsReady(false);
}
}, [visible]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (visible && activeTab === "download" && !hasSearchedThisSession) {
search({ language: selectedLanguage });
setHasSearchedThisSession(true);
}
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
useEffect(() => {
if (isReady) {
setIsTabContentReady(false);
const timer = setTimeout(() => setIsTabContentReady(true), 50);
return () => clearTimeout(timer);
}
setIsTabContentReady(false);
}, [activeTab, isReady]);
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
search({ language: code });
},
[search],
);
const handleTrackSelect = useCallback(
(index: number) => {
onSubtitleIndexChange(index);
onClose();
},
[onSubtitleIndexChange, onClose],
);
const handleDownload = useCallback(
async (result: SubtitleSearchResult) => {
setDownloadingId(result.id);
try {
const downloadResult = await downloadAsync(result);
if (downloadResult.type === "server") {
onServerSubtitleDownloaded?.();
} else if (downloadResult.type === "local" && downloadResult.path) {
onLocalSubtitleDownloaded?.(downloadResult.path);
}
onClose();
} catch (error) {
console.error("Failed to download subtitle:", error);
} finally {
setDownloadingId(null);
}
},
[
downloadAsync,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
onClose,
],
);
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
const trackOptions = useMemo(() => {
const noneOption = {
label: t("item_card.subtitles.none"),
sublabel: undefined as string | undefined,
value: -1,
selected: currentSubtitleIndex === -1,
};
const options = subtitleTracks.map((track) => ({
label:
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
sublabel: track.Codec?.toUpperCase(),
value: track.Index!,
selected: track.Index === currentSubtitleIndex,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
{/* Tab bar */}
<View style={styles.tabRow}>
<TVTabButton
label={t("item_card.subtitles.tracks") || "Tracks"}
active={activeTab === "tracks"}
onSelect={() => setActiveTab("tracks")}
/>
<TVTabButton
label={t("player.download") || "Download"}
active={activeTab === "download"}
onSelect={() => setActiveTab("download")}
/>
</View>
</View>
{/* Tracks Tab Content */}
{activeTab === "tracks" && isTabContentReady && (
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tracksScroll}
contentContainerStyle={styles.tracksScrollContent}
>
{trackOptions.map((option, index) => (
<TVTrackCard
key={option.value}
ref={
index === initialSelectedTrackIndex
? firstTrackRef
: undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedTrackIndex}
onPress={() => handleTrackSelect(option.value)}
/>
))}
</ScrollView>
</View>
)}
{/* Download Tab Content */}
{activeTab === "download" && isTabContentReady && (
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<TVLanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults &&
searchResults.length > 0 &&
!isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<TVSubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</>
)}
{/* Cancel button */}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={onClose}
label={t("common.cancel") || "Cancel"}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
maxHeight: "70%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 48,
},
header: {
paddingHorizontal: 48,
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
tabRow: {
flexDirection: "row",
gap: 24,
},
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
},
tracksScroll: {
overflow: "visible",
},
tracksScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.6)",
marginTop: 12,
fontSize: 14,
},
errorContainer: {
paddingVertical: 40,
paddingHorizontal: 48,
alignItems: "center",
},
errorText: {
color: "rgba(255,100,100,0.9)",
marginTop: 8,
fontSize: 16,
fontWeight: "500",
},
errorHint: {
color: "rgba(255,255,255,0.5)",
marginTop: 4,
fontSize: 13,
textAlign: "center",
},
emptyContainer: {
paddingVertical: 40,
alignItems: "center",
},
emptyText: {
color: "rgba(255,255,255,0.5)",
marginTop: 8,
fontSize: 14,
},
apiKeyHint: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 48,
paddingTop: 8,
},
apiKeyHintText: {
color: "rgba(255,255,255,0.4)",
fontSize: 12,
},
cancelButtonContainer: {
paddingHorizontal: 48,
paddingTop: 20,
alignItems: "flex-start",
},
});

View File

@@ -342,6 +342,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info?.cacheSeconds !== undefined && (
<Text style={textStyle}>
Buffer: {info.cacheSeconds.toFixed(1)}s
{info?.demuxerMaxBytes !== undefined
? ` (cap ${info.demuxerMaxBytes}MB` +
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
")"
: ""}
</Text>
)}
{info?.voDriver && (
@@ -350,6 +356,12 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
{info.hwdec ? ` / ${info.hwdec}` : ""}
</Text>
)}
{info?.estimatedVfFps !== undefined && (
<Text style={textStyle}>
Output FPS: {info.estimatedVfFps.toFixed(2)}
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
</Text>
)}
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
<Text style={[textStyle, styles.warningText]}>
Dropped: {info.droppedFrames} frames

View File

@@ -1,39 +0,0 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export default {
const MediaTypes = {
Audio: "Audio",
Video: "Video",
Photo: "Photo",
Book: "Book",
};
} as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
Relevant files:
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)

View File

@@ -52,7 +52,7 @@
}
},
"production": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -64,7 +64,7 @@
}
},
"production-apk": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -74,7 +74,7 @@
}
},
"production-apk-tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -87,7 +87,7 @@
}
},
"production_tv": {
"bun": "1.3.5",
"bun": "1.3.14",
"environment": "production",
"autoIncrement": true,
"env": {
@@ -97,6 +97,14 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
}
},
"submit": {

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -1,35 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

108
hooks/useFilterReset.ts Normal file
View File

@@ -0,0 +1,108 @@
import { useAtom } from "jotai";
import { useCallback } from "react";
import {
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
genrePreferenceAtom,
SortByOption,
SortOrderOption,
sortByAtom,
sortByPreferenceAtom,
sortOrderAtom,
sortOrderPreferenceAtom,
tagPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
yearPreferenceAtom,
} from "@/utils/atoms/filters";
/**
* Single source of truth for the library filter bar's "reset" action and its
* visibility. The mobile ResetFiltersButton and the TV filter header both use
* this so they can't drift — sort/order used to be reset on neither path, so
* the reset (X) never reflected a changed sort.
*
* A reset clears the session filters AND the per-library in-memory preferences
* (sort, order, filterBy, genres, years, tags); otherwise the saved preference
* resurfaces when the library's mount effect re-applies it on the next entry.
*/
export const useFilterReset = (libraryId: string) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [filterBy, setFilterBy] = useAtom(filterByAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [, setSortOrderPreference] = useAtom(sortOrderPreferenceAtom);
const [, setFilterByPreference] = useAtom(FilterByPreferenceAtom);
const [, setGenrePreference] = useAtom(genrePreferenceAtom);
const [, setYearPreference] = useAtom(yearPreferenceAtom);
const [, setTagPreference] = useAtom(tagPreferenceAtom);
// SortName / Ascending is the baseline a library opens with (mount-effect
// fallback), so any other value counts as an active, resettable sort.
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0 ||
sortBy[0] !== SortByOption.SortName ||
sortOrder[0] !== SortOrderOption.Ascending;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
setFilterBy([]);
setSortBy([SortByOption.SortName]);
setSortOrder([SortOrderOption.Ascending]);
setSortByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setSortOrderPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setFilterByPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setGenrePreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setYearPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
setTagPreference((prev) => {
const next = { ...prev };
delete next[libraryId];
return next;
});
}, [
libraryId,
setSelectedGenres,
setSelectedYears,
setSelectedTags,
setFilterBy,
setSortBy,
setSortOrder,
setSortByPreference,
setSortOrderPreference,
setFilterByPreference,
setGenrePreference,
setYearPreference,
setTagPreference,
]);
return { hasActiveFilters, resetAllFilters };
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

View File

@@ -70,30 +70,6 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -474,8 +450,7 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
}, []);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -4,41 +4,42 @@ import { Platform } from "react-native";
import {
disableTVMenuKeyInterception,
enableTVMenuKeyInterception,
useTVBackPress,
} from "./useTVBackPress";
export { enableTVMenuKeyInterception } from "./useTVBackPress";
/** All tab route names used in the bottom tab navigator. */
export const TAB_ROUTES = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(custom-links)",
"(settings)",
] as const;
export type TabRoute = (typeof TAB_ROUTES)[number];
/** Check if a segment string is a tab route. */
export function isTabRoute(s: string): s is TabRoute {
return (TAB_ROUTES as readonly string[]).includes(s);
}
/**
* Check if we're at the root of a tab
*/
function isAtTabRoot(segments: string[]): boolean {
const lastSegment = segments[segments.length - 1];
const tabNames = [
"(home)",
"(search)",
"(favorites)",
"(libraries)",
"(watchlists)",
"(settings)",
"(custom-links)",
];
return tabNames.includes(lastSegment) || lastSegment === "index";
return isTabRoute(lastSegment) || lastSegment === "index";
}
/**
* Get the current tab name from segments
*/
function getCurrentTab(segments: string[]): string | undefined {
return segments.find(
(s) =>
s === "(home)" ||
s === "(search)" ||
s === "(favorites)" ||
s === "(libraries)" ||
s === "(watchlists)" ||
s === "(settings)" ||
s === "(custom-links)",
);
function getCurrentTab(segments: string[]): TabRoute | undefined {
return segments.find(isTabRoute);
}
/**
@@ -49,7 +50,6 @@ function getCurrentTab(segments: string[]): string | undefined {
export function useTVHomeBackHandler() {
const segments = useSegments();
// Get current state
const currentTab = getCurrentTab(segments);
const atTabRoot = isAtTabRoot(segments);
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
@@ -65,3 +65,24 @@ export function useTVHomeBackHandler() {
enableTVMenuKeyInterception();
}, [isOnHomeRoot]);
}
/**
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
*
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
* built-in tab-level back handling — pressing back at a tab root would pop the
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
* to Home instead.
*/
export function useTVTabRootBackHandler(
onNavigateHome: () => void,
isAtTabRoot: boolean,
currentTab: string | undefined,
) {
useTVBackPress(() => {
if (!Platform.isTV || Platform.OS !== "android") return false;
if (!isAtTabRoot || currentTab === "(home)") return false;
onNavigateHome();
return true;
}, [isAtTabRoot, currentTab, onNavigateHome]);
}

View File

@@ -12,6 +12,7 @@ interface ShowOptionsParams<T> {
onSelect: (value: T) => void;
cardWidth?: number;
cardHeight?: number;
deferApplyUntilDismissed?: boolean;
}
export const useTVOptionModal = () => {
@@ -26,6 +27,7 @@ export const useTVOptionModal = () => {
onSelect: params.onSelect,
cardWidth: params.cardWidth,
cardHeight: params.cardHeight,
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
});
router.push("/(auth)/tv-option-modal");
},

View File

@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);

View File

View File

@@ -2,7 +2,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".DownloadService"

View File

@@ -9,6 +9,7 @@ import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
@@ -27,6 +28,7 @@ class DownloadService : Service() {
private var currentDownloadTitle = "Preparing download..."
private var currentProgress = 0
private var isForegroundStarted = false
private var wakeLock: PowerManager.WakeLock? = null
inner class DownloadServiceBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
@@ -36,6 +38,12 @@ class DownloadService : Service() {
super.onCreate()
Log.d(TAG, "DownloadService created")
createNotificationChannel()
val pm = getSystemService(PowerManager::class.java)
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Streamyfin::DownloadWakeLock")
wakeLock?.acquire()
Log.d(TAG, "Wake lock acquired")
}
override fun onBind(intent: Intent?): IBinder {
@@ -93,6 +101,8 @@ class DownloadService : Service() {
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
Log.d(TAG, "Wake lock released")
Log.d(TAG, "DownloadService destroyed")
super.onDestroy()
}

View File

@@ -53,5 +53,5 @@ android {
dependencies {
// libmpv from Maven Central
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
}

Some files were not shown because too many files have changed in this diff Show More