Compare commits

...

473 Commits

Author SHA1 Message Date
Gauvain
6e35785a5c fix(casting): exclude Virtual (missing) episodes from cast episode list & nav
The cast player derives the episode list, prev/next buttons and autoplay
target from fetchSeriesEpisodes, which returned every episode including
"Virtual" (missing) ones — e.g. an empty Specials/Season 0 entry. That
made a bogus "previous" button appear on S1E1 (its previous was the missing
S0E1) and could load/autoplay an unplayable episode. Filter them out at the
source so they never surface in the cast UI.
2026-06-02 00:32:59 +02:00
Gauvain
c7c3aa8a34 fix(player): correct prev/next episode buttons at boundaries & for missing episodes
Previous/next were derived from the adjacentTo response's array length,
which wrongly surfaced the current episode as "previous" on the first
episode (e.g. S1E1) and offered "Virtual" (missing) episodes — like an
empty Specials entry — as navigation/autoplay targets.

Derive them from the current item's actual index in the list and skip
neighbours that are the current item or have LocationType 'Virtual'. This
also stops autoplay from advancing into a missing episode.
2026-06-02 00:04:00 +02:00
Gauvain
211923b2ab fix(casting): improve current-episode contrast in episode list
The active row used a solid #a855f7 background, which hid the purple
S:E label entirely and washed out the gray overview/metadata text.
Use a translucent purple so the dark base shows through and all text
stays readable; the play-circle icon still marks the current episode.
2026-06-01 23:59:35 +02:00
Gauvain
f4a68bca10 chore: stop tracking docs/superpowers workflow artifacts
Local superpowers planning docs (handoff/plans/specs), never meant for
the repo. Untrack them; files kept locally.
2026-06-01 23:18:33 +02:00
Gauvain
985cb0f337 Merge origin/develop into refactor-chromecast
Bring 323 commits of develop (incl. the Expo SDK 56 / TV-branch work) into
the chromecast refactor. Conflict resolutions:

- chapters: take develop's reviewed version (ChapterList/ChapterTicks/
  chapters.ts/test) — adds chapterNameAt, markers API, themed Colors.
- auto-skip: keep chromecast's unified useSegmentSkipper for the phone
  player; restore develop's useCreditSkipper/useIntroSkipper (deleted on
  chromecast) so develop's Controls.tv.tsx compiles. TV->useSegmentSkipper
  migration left as follow-up.
- en.json: union the two player blocks (kept chromecast casting keys +
  develop's subtitle/playback keys).
- TechnicalInfoOverlay/PlatformDropdown: take develop's TV-safe versions
  (kept chromecast's disabled-prop branch, aliased to avoid shadowing the
  @expo/ui disabled modifier).
- SDK 56 fixes: expo-router Router -> ImperativeRouter in cast components;
  ChapterTicks markers API in CastPlayerProgressBar.
- restore utils/profiles/chromecast* (deleted on chromecast, still used by
  PlayButton).

Typecheck passes; bun.lock regenerated against merged package.json.
2026-06-01 23:14:35 +02:00
Fredrik Burmester
46bd2a784e fix(tv): keep focus on search field instead of jumping to results grid (#1637)
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 / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🚀 Release (EAS build + submit) / 🔐 Approve release (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 Android TV APK (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🍎 iOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 📺 tvOS (push) Has been cancelled
🚀 Release (EAS build + submit) / 🚀 🤖 Android AAB (push) Has been cancelled
🚀 Release (EAS build + submit) / 📦 Draft GitHub Release (push) Has been cancelled
2026-06-01 21:50:52 +02:00
Fredrik Burmester
0a36fdfbec fix: icon alignment library header
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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-01 19:55:55 +02:00
Fredrik Burmester
45d1f752d6 fix: header left button icon alignment 2026-06-01 19:46:07 +02:00
lance chant
54ee507209 fix: fixing the time variable (#1638)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 15:22:39 +02:00
lance chant
338fb9713b fix: qr code scanning not working ios (#1619)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:54 +02:00
lance chant
939fd2512d fix: max episodes count (#1554)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 12:38:34 +02:00
github-actions[bot]
32c99de874 feat: New Crowdin Translations (#1460)
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
🚦 Security & Quality Gate / 📝 Validate PR Title (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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-01 10:33:17 +02:00
Fredrik Burmester
c232e433bf fix(ci): grant actions:read to github-release job (#1634) 2026-06-01 10:31:05 +02:00
Fredrik Burmester
07e2faff07 fix(tv): only add user-management entitlement for tvOS builds (#1633) 2026-06-01 10:28:21 +02:00
Fredrik Burmester
8507699cdd feat(eas): force bun on EAS via custom build configs + 5-build release workflow (#1632) 2026-06-01 10:24:52 +02:00
Gauvain
21fb056586 fix(i18n): make two hardcoded titles translatable (#1627) 2026-06-01 09:46:27 +02:00
Gauvain
1d79b513f3 fix(item): dedupe top people sections by id (#1623) 2026-06-01 09:37:45 +02:00
Gauvain
863dffd944 fix(chapters): keep landscape when opening chapter list on iOS (#1624) 2026-06-01 09:37:35 +02:00
lance chant
6aa0868bfd fix: fixed a runtime issue for android (#1628)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-01 09:35:19 +02:00
Felix Schneider
6b7ee0514f feat(i18n): add new translations for action sheet options (#1475)
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
🌐 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
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 23:45:45 +02:00
Fredrik Burmester
c663bd0413 fix(jellyseerr): correct RequestModal ref type to fix typecheck
advancedReqModalRef was typed as BottomSheetModal but RequestModal's
forwardRef expects BottomSheetModalMethods, causing a TS2322 error
that broke the Security & Quality Gate typecheck on develop.
2026-05-31 22:10:15 +02:00
Alex
52e6f56220 fix(auth): clear stored user on logout to prevent empty home on relaunch (#1622) 2026-05-31 21:52:41 +02:00
Fredrik Burmester
c981f59a50 fix(downloads): repair bottom sheets on iOS and restore downloads delete sheet
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
Bump @gorhom/bottom-sheet 5.2.8 -> 5.2.14, which fixes BottomSheetModal
present() silently no-opping under Reanimated 4 / New Architecture (SDK 56).
Affected every sheet app-wide: present() was called with a valid ref but
nothing rendered (not even the backdrop). Verified on the iOS simulator that
the download options sheet now opens.

Also restore the downloads-page delete sheet (delete movies/series/other/all)
that was accidentally dropped in the Expo 54 rewrite (#1174), which left an
orphaned trigger button and underscore-silenced handlers.
2026-05-31 14:58:51 +02:00
Alex
62fc6f9a70 fix(progress-bar): Fix progress bar not reporting watch times (#1611)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 22:12:13 +10:00
Fredrik Burmester
eb8dd51b4e feat(ci): EAS build + auto-submit release workflow for main (#1616) 2026-05-31 13:23:02 +02:00
Fredrik Burmester
ea5a999f21 fix(deps): declare react-native-tab-view and material-top-tabs (#1617) 2026-05-31 12:40:56 +02:00
Felix Schneider
dffcdef945 feat(i18n): Add translation for "ends at" (#1474)
Co-authored-by: Gauvain <contact@uruk.dev>
2026-05-31 12:10:22 +02:00
Fredrik Burmester
fa1c3f3947 chore(eas): pin appleTeamId and ascAppId in submit profiles
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 / 📝 Validate PR Title (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
Avoid the interactive Apple team picker and app-existence lookup on
submit by pinning the Individual team (MWD5K362T8) and ASC App ID.
2026-05-31 11:22:59 +02:00
Fredrik Burmester
2761de5a74 chore(eas): use remote app version source with autoIncrement
Switch cli.appVersionSource to remote and enable autoIncrement on all
production profiles so EAS bumps the build number every release instead
of resetting to 1. Remove the dead android.versionCode from app.json and
the unused EAS Update channel (no expo-updates installed).
2026-05-31 11:22:59 +02:00
Fredrik Burmester
feca1d7e9c fix(android): resolve mpv-player Kotlin smart-cast build error (#1614) 2026-05-31 10:50:06 +02:00
Fredrik Burmester
6b6bfd1a89 fix(player): remove white blob artifacts on vertical sliders 2026-05-31 10:48:24 +02:00
Fredrik Burmester
d585b20f49 chore: version 2026-05-31 09:44:05 +02:00
Fredrik Burmester
692ccfdb2c fix(tvos): add arm64 UIRequiredDeviceCapabilities to Top Shelf extension
App Store Connect rejected TestFlight submissions because the Top Shelf
extension binary has a 64-bit slice but did not declare arm64 under
UIRequiredDeviceCapabilities in its Info.plist.
2026-05-31 09:42:09 +02:00
Fredrik Burmester
86e39c444c fix(ios): SDK 56 / iOS 26 EAS build fixes (SwiftUICore autolink + patch-package) (#1613) 2026-05-31 09:37:08 +02:00
Fredrik Burmester
ed7928b4d3 Merge remote-tracking branch 'origin/feat/tv-interface' into develop
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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-30 22:47:33 +02:00
lance chant
27dc7b5664 fix: android pip (#1605)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 22:45:19 +02:00
Alex
a205c75895 chore(mpv-player): Update to MPVKIT 0.41 (#1604) 2026-05-30 22:45:07 +02:00
Fredrik Burmester
a4d4f588fb Merge remote-tracking branch 'origin/chore/sdk-56-migration' into feat/tv-interface
# Conflicts:
#	bun.lock
#	package.json
2026-05-30 21:44:02 +02:00
Fredrik Burmester
aedb7bc51d fix(tv): padding 2026-05-30 21:41:27 +02:00
Fredrik Burmester
5a3e9c51c9 fix(tv): align search skeleton, raise search field, fix up-focus
- Match the loading skeleton to TVSearchSection's scaled layout (poster
  width, item gap, edge padding, heading, poster radius) so placeholders
  line up with the real content.
- Move the native search field up ~50px (drop marginTop).
- Remove the downward focus guide that re-captured upward focus, so
  pressing up from the native search now reaches the tab bar.
2026-05-30 21:21:37 +02:00
Fredrik Burmester
252c58f120 fix(tv): lazy-load @expo/ui to prevent tvOS crash at module load 2026-05-30 21:21:22 +02:00
Fredrik Burmester
d2e73021b1 fix(mpv): apply carried audio/subtitle track after file load
Setting sid/aid before loadfile does not stick for embedded tracks, so a
carried-over subtitle was silently dropped on a freshly loaded episode.
Apply the initial audio/subtitle selection in the MPV_EVENT_FILE_LOADED
handler (after tracks are enumerated) for embedded and external alike, on
both iOS and Android.
2026-05-30 20:15:37 +02:00
Fredrik Burmester
6876ce046f fix(player): retain subtitle language and mode across episodes
Carry the live subtitle/audio selection to the next episode on all TV
navigation paths (next/prev buttons, autoplay) and feed TV subtitle modal
selections back into player state via onSubtitleIndexChange so the chosen
track is what gets carried.

Rank subtitles by language plus forced/hearing-impaired mode, with a
no-language fallback (mode + codec + position), and use an explicit match
flag so a deliberate "off" selection is retained too.
2026-05-30 20:15:24 +02:00
Fredrik Burmester
e044859aaf feat(tv): native tvOS search field via SwiftUI .searchable
Add a local `tv-search` Expo module that hosts SwiftUI's `.searchable`
in a UIHostingController (adapted from expo-tvos-search, minus its native
results grid). It emits typed text to React Native so the existing search
pipeline and custom TV results grid are reused. Handles the RN-tvOS remote
gesture release needed for keyboard input on device.

Wire it into TVSearchPage as a sticky header above the scrollable results,
with a TVFocusGuideView bridge so focus can move from the tab bar into the
native search field.
2026-05-30 16:54:35 +02:00
Fredrik Burmester
c93132177c fix(tv): scale search input font and box with tvTypographyScale setting
The TV search input hardcoded fontSize and box dimensions, so it ignored
the TV display size setting. Drive font, height, padding, and icon from
the scaled `body` typography token so the whole component scales.
2026-05-30 13:29:56 +02:00
Fredrik Burmester
2166bb3867 feat(sync): auto-refresh on Jellyfin LibraryChanged events
Handle the server's LibraryChanged WebSocket message to invalidate
library-dependent React Query caches when items are added/updated/
removed, so newly added episodes/movies appear without a manual
refresh. Debounced to coalesce a scan's burst of events.

Add useRefreshLibraryOnFocus as a fallback that re-checks on screen
focus (throttled, online-only, skips first focus), wired into home
(mobile + TV) and the library pages.
2026-05-30 13:05:43 +02:00
Fredrik Burmester
f9b71ef648 style(login): center server-connect layout and adjust logo position 2026-05-30 12:25:34 +02:00
Fredrik Burmester
d11fb3d0c0 fix(dropdown): use nested Menu submenus for grouped options on iOS
Render titled option groups as nested Menu submenus instead of flat
Pickers, and convert the Discover filters from ContextMenu to Menu.
Keeps single-tap-to-open behavior (ContextMenu requires a long press
and reads as a context menu) while giving the nicer nested grouping.
2026-05-30 12:24:39 +02:00
Fredrik Burmester
dd3ca37108 refactor(settings): convert login-tv card to ListGroup/ListItem 2026-05-30 12:00:45 +02:00
Fredrik Burmester
b588195456 fix(search): correct Library/Discover tab layout for @expo/ui SDK 55
Native Button no longer renders RN <Text> children in SDK 55; use the
label prop. Wrap both buttons in a single Host + HStack with a trailing
Spacer so they sit flush-left with no centering inset.
2026-05-30 11:56:49 +02:00
Fredrik Burmester
fe4d90df26 fix(dropdown): make all stacked dropdowns visible in download sheet
@expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
setStyleSize, so it can't size to content. With the Host's flex:1 height
depending on a zero-size wrapper, a circular dependency collapsed every
selector nested more than one level deep — only the first (Quality) stayed
visible in the download sheet.

Pin the wrapper View to the measured trigger size and let the Host fill it
via absoluteFill, breaking the cycle so Video/Audio/Subtitle render too.
2026-05-30 11:50:03 +02:00
Fredrik Burmester
a190122eea Merge branch 'develop' into feat/tv-interface
# Conflicts:
#	.gitignore
#	app/(auth)/(tabs)/(home)/_layout.tsx
#	components/PlatformDropdown.tsx
#	components/search/DiscoverFilters.tsx
#	components/video-player/controls/BottomControls.tsx
#	components/video-player/controls/TrickplayBubble.tsx
#	modules/mpv-player/ios/MPVLayerRenderer.swift
2026-05-30 11:08:22 +02:00
Fredrik Burmester
0bf8fac079 chore: update pr template
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
2026-05-30 10:41:24 +02:00
Fredrik Burmester
37b51abd34 chore: deps 2026-05-30 10:41:10 +02:00
Fredrik Burmester
6fe464088b fix(mpv): prevent UI freeze on player exit by tearing down mpv off main thread
mpv_terminate_destroy() blocks until mpv's threads (including the
vo_avfoundation output thread) are joined, and that teardown needs the
main run loop to complete. Calling it via queue.sync from MpvPlayerView
deinit (main thread) deadlocked/froze the UI on playback exit.

Remove the wakeup callback synchronously while self is still alive, then
run mpv_terminate_destroy on the serial queue via async so deinit returns
immediately and the main thread is never blocked. Also release the PiP
timebase/controller in deinit.
2026-05-30 10:40:10 +02:00
Fredrik Burmester
769c7a2432 fix: restore nested dropdown sections for expo 55 on iOS using nested ContextMenus 2026-05-30 10:13:40 +02:00
Fredrik Burmester
62c86533b1 fix(settings): preserve app defaults for unlocked plugin values 2026-05-30 10:03:56 +02:00
Fredrik Burmester
4fc78f006d fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
ab0957044f fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
407ef3f51e chore: add skills to gitignore 2026-05-30 10:03:55 +02:00
Fredrik Burmester
0e531da2e0 fix(watchlists): invalidate watchlists list query on add/remove 2026-05-30 10:03:55 +02:00
Lance Chant
1cabbf087e fix: player getting stuck on timer and exit
Fixed a race condition where the upnext countdown started and a user
cancelled/stop the current playback that they would exit the player but
the timer would still be running and then start playing the next episode
and you wouldn't be able to press back or exit out of it

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-30 10:03:19 +02:00
Gauvain
0f86c776ba feat(player): add chapter markers and chapter list (#1586)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-05-30 09:22:23 +02:00
lostb1t
07b79de203 fix: Do not cache background request for mediasources (#1602) 2026-05-30 09:11:59 +02:00
Gauvain
e4b0161d15 fix(ios): patch react-native-ios-utilities for RN 0.85 prebuilt React
With SwiftUICore resolved (prev commit), the app link surfaced the real blocker:
`Undefined symbols: _OBJC_CLASS_$_RCTRootContentView`, referenced from
react-native-ios-utilities (RCTView+Helpers.o). RCTRootContentView is a legacy
paper class that the prebuilt new-architecture React in RN 0.85 no longer exports.
ios-utilities@5.2.0 is the latest release and still references it, so no version
bump fixes this.

Patch the single offending helper (closestParentReactContentView) to return nil
with an RCTView? type, dropping the only RCTRootContentView reference in the pod.
It feeds only the last-resort touch-handler fallback, moot under the new arch.
Nothing else (incl. react-native-ios-context-menu) references it.

NOTE: react-native-ios-utilities + react-native-ios-context-menu (both Dominic's,
latest 5.2.0 / 3.2.1) are effectively unmaintained for RN 0.85 — candidates for
removal/replacement (context-menu is used only in DiscoverFilters.tsx), like udp.
2026-05-29 18:51:52 +02:00
Gauvain
6e223596f6 fix(ios): drop SwiftUICore autolink on pods so the app links via SwiftUI re-export
Build #9 proved `-weak_framework SwiftUICore` does NOT bypass the allowed-client
check, and applying it to the tvOS app target regressed tvOS — reverted that
plugin (withSwiftUICoreWeakLink).

Confirmed root cause from build #8/#9 logs: both iOS jobs fail at the app
*executable* link (`Ld … Streamyfin`), not at any pod. SwiftUI was split into
SwiftUI + SwiftUICore on iOS 26; the SwiftUI pods emit a `-framework SwiftUICore`
autolink directive that, under use_frameworks :static, is inherited by the app's
static link, and the app isn't an allowed client of the private SwiftUICore.tbd.

Fix: in the pod post_install, compile pods with
`-Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore` so they stop
emitting that direct autolink. SwiftUICore symbols then resolve through SwiftUI's
re-export (SwiftUI.tbd re-exports SwiftUICore). Scoped to phone
(ENV['EXPO_TV'] != '1') to leave the green tvOS build untouched.

Also harden scripts/ios/build-ios.ts: displayBuildError now surfaces the
"Undefined symbols for architecture …" linker block, which the error:-only
pattern filter was swallowing (so unsigned-build failures show the real symbol).
2026-05-29 18:17:02 +02:00
Gauvain
04e75c81a4 fix(ios): weak-link SwiftUICore on app target to bypass Xcode 26 autolink error
Build #8 confirmed BOTH iOS jobs (signed + unsigned) fail at the same step:
the Streamyfin app-target link (`Ld ... Streamyfin`), not any pod framework.
Under use_frameworks static + Xcode 26 the SwiftUI pods' object files carry a
`-framework SwiftUICore` autolink directive that flows into the app link; ld
rejects it with "cannot link directly with 'SwiftUICore' because product being
built is not an allowed client of it".

forceStaticLinking the SwiftUI pods was treating a symptom. The real fix is to
weakly link SwiftUICore on the app target so the allowed-client check is
bypassed and the symbols resolve via SwiftUI's re-export at runtime.

New plugin withSwiftUICoreWeakLink scopes the flag to product-type application
only, leaving the tvOS TopShelf app-extension untouched (a broad weak-link
previously broke that target).
2026-05-29 17:38:01 +02:00
Gauvain
b6ea6d4f14 test(ios): add GlassPoster to forceStaticLinking (local SwiftUI module)
Build #7: forceStaticLinking ExpoUI+GlassEffectView worked (both now static libs) but GlassPoster (local SwiftUI module, modules/glass-poster) was still built as a framework and kept auto-linking SwiftUICore. Add it to the list. [unsigned: GPG]
2026-05-29 16:50:52 +02:00
Gauvain
2c58636843 test(ios): Path A - keep useFrameworks:static + forceStaticLinking ExpoUI/GlassEffectView
Pivot: removing useFrameworks fixed SwiftUICore but broke legacy pods (udp <React/...>), and use_modular_headers! didn't help (prebuilt React VFS). Instead keep useFrameworks:static (udp & all legacy pods keep working) and force-static-link the SwiftUI pods (ExpoUI=@expo/ui, GlassEffectView) so they stop propagating the SwiftUICore framework auto-link to the app target. forceStaticLinking is the documented expo-build-properties fix for Swift pods that break under static frameworks. [unsigned: GPG]
2026-05-29 16:21:43 +02:00
Fredrik Burmester
d9266209d2 Merge remote-tracking branch 'origin/feat/tv-interface' into chore/sdk-56-migration 2026-05-29 08:45:12 +02:00
Gauvain
4cc11403f8 chore(deps): bump Renovate-proven JS dependencies (minors + i18n) (#1599) 2026-05-29 08:36:07 +02:00
Gauvain
0ba3f44615 chore: upgrade Biome to 2.4.16, clean up lint, and fix TV password modal (#1598) 2026-05-29 08:32:21 +02:00
Gauvain
a3ed822bf4 test(ios): use_modular_headers! for legacy pods after dropping useFrameworks
Without useFrameworks:static, old Obj-C pods (react-native-udp -> <React/RCTAssert.h>) lose the React umbrella header. use_modular_headers! restores module header maps so <React/...> resolves for udp and any other legacy pod, in one shot. SwiftUICore already gone (build #5). udp is abandoned (4.1.7, 2023) with no drop-in replacement, so patching headers beats replacing it. [unsigned: GPG]
2026-05-29 08:20:05 +02:00
Gauvain
f8414194f0 test(ios): drop useFrameworks:static (root of SwiftUICore + RNScreens link errors)
useFrameworks:static is the common root of the iOS 26 link failures (SwiftUICore auto-link + the original RNScreens issue) and is broadly broken on SDK 55/56 (expo/expo #44487 etc.). It is NOT mandatory for react-native-google-cast (static Cast SDK works without it). Removing it so all pods build as static libs (the New Arch default). Verifying via CI; google-cast runtime needs device check. [unsigned: GPG unavailable]
2026-05-29 08:01:09 +02:00
Gauvain
1e9c9fb67f revert(ios): drop SwiftUICore weak-link plugin (broke tvOS, no iOS fix)
The -weak_framework SwiftUICore approach did NOT resolve the iOS 26 'cannot link directly with SwiftUICore' link error (auto-linked by a precompiled SwiftUI pod - @expo/ui and/or react-native-glass-effect-view, both in use), and it broke the tvOS TopShelf target (no SwiftUICore on tvOS). Restores tvOS unsigned to green. iOS phone still blocked on the SwiftUICore autolink issue - likely tied to useFrameworks:static + @expo/ui on iOS 26; needs a macOS build to iterate. [unsigned: GPG unavailable while away]
2026-05-29 01:27:57 +02:00
Gauvain
3c7292b73b fix(ios): weak-link SwiftUICore for iOS 26 build (SDK 56)
iOS phone archive failed at Ld: "cannot link directly with 'SwiftUICore' because product being built is not an allowed client of it" (a Liquid Glass / SwiftUI pod pulls SwiftUICore). Add a Podfile config plugin that weak-links SwiftUICore on the app target(s). iOS-only; tvOS unsigned already builds. [unsigned: GPG passphrase unavailable while user away; re-sign on merge]
2026-05-29 00:50:57 +02:00
Gauvain
708d0e8d2e chore(deps): bump safe JS dependencies on SDK 56
JS-only, typecheck-clean, no app source changes:
- lodash 4.17.23 -> 4.18.1 (security)
- @tanstack/react-query 5.90.20 -> 5.100.14 (+ query persisters ^5.100.14)
- jotai 2.16.2 -> 2.20.0
- zod 4.1.13 -> 4.4.3
- i18next ^25 -> ^26.3.0, react-i18next 16.5.3 -> 17.0.8 (already merged on develop)
- @types/lodash 4.17.24

Held back (need app changes / runtime testing): @gorhom/bottom-sheet 5.2.14,
@tanstack/react-pacer 0.22, sonner-native 0.25, react-native-url-polyfill 3.
tailwind/nativewind left untouched on purpose.

[unsigned: GPG passphrase unavailable while user away; re-sign/squash on merge]
2026-05-29 00:44:02 +02:00
Gauvain
74f5844ed7 fix(build): unblock SDK 56 Android + iOS builds
- app.json: kotlinVersion 2.0.21 -> 2.1.20 (Expo SDK 56 modules require >= 2.1.20)
- re-add @react-navigation/native: it is a peer dependency of
  @bottom-tabs/react-navigation and was wrongly removed; its absence broke the
  iOS eager JS bundle (expo export:embed) during the native build.

Local `expo export -p ios` now bundles cleanly. SDK 56 disables USE_FRAMEWORKS
for RNScreens/ReactCodegen, which resolves the original signed-iOS RNScreens
static-frameworks build failure.
2026-05-29 00:16:15 +02:00
Gauvain
38d638cdeb chore(deps): migrate to Expo SDK 56 (Phase 1 - compat)
Compatibility migration from SDK 55 to SDK 56 (react-native-tvos 0.85.3-0,
React 19.2.3). Phase 1 = breaking changes needed to build; new-feature
adoption and TypeScript 6 are deferred to Phase 2.

- Deps aligned to SDK 56 via `expo install --fix` (all expo-* 56.x, screens
  4.25.2, reanimated 4.3.1, worklets 0.8.3, gesture-handler 2.31.x, svg 15.15.4)
- react-native -> react-native-tvos@0.85.3-0; react/react-dom 19.2.3
- expo-router forked React Navigation: ran the SDK 56 codemod
  (@react-navigation/* imports -> expo-router/*), removed the 3 now-unused
  direct @react-navigation/* dependencies, retyped NestedTabPageStack via
  expo-router Stack.Screen options
- StyleSheet.absoluteFillObject -> absoluteFill (removed from RN 0.85 types)
- app.json ios.deploymentTarget 15.6 -> 16.4 (SDK 56 minimum)
- CI: Xcode 26.2 -> 26.4; made xcode-version Renovate-managed via a
  customManager + xcodereleases customDatasource
- @babel/core 7.29.7; dropped version-locked screens/codegen bun-patches
  (no longer applicable on SDK 56)

Deferred to Phase 2: TypeScript 6 (toolchain: @types/node, jest globals,
UdpSocket typing), @expo/vector-icons -> @react-native-vector-icons codemod.

typecheck passes. expo-doctor: 2 known failures remain (react-native-track-player
New Arch fork; typescript major mismatch pending the deferred TS6 bump).
2026-05-28 23:56:03 +02:00
Fredrik Burmester
afe9d33ee4 fix(topshelf): use .tvtopshelf bundle id and app group
The original com.fredrikburmester.streamyfin.TopShelf bundle id and
group.com.fredrikburmester.streamyfin app group were reserved by Apple
(previously created and deleted), so they could not be re-registered.
Switch the extension bundle id and shared app group to .tvtopshelf.
2026-05-28 22:03:09 +02:00
Gauvain
5db4a79e8a ci: enable unsigned tvOS build (signed stays gated on EAS tvOS credentials) 2026-05-28 20:46:21 +02:00
Lance Chant
c12f252079 fix: sorted app languages
Sorting the app languages by alphabetical order

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-28 15:15:13 +02:00
Fredrik Burmester
70a0033094 chore: version 2026-05-28 11:41:51 +02:00
Fredrik Burmester
3379cedc01 fix(mpv-player): split combined delegate method back into separate HDR detection and audio output selection callbacks 2026-05-28 11:26:48 +02:00
Fredrik Burmester
63adb98540 fix(mpv-player): add missing closing brace to configureAudioSession 2026-05-28 11:22:51 +02:00
Lance Chant
4f721c3ab6 Merge remote-tracking branch 'origin/develop' into feat/tv-interface
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-28 09:05:20 +02:00
renovate[bot]
cf91c4c682 chore(deps): Update dependency @react-native-community/netinfo to v12 (#1457)
Some checks failed
🛡️ 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
🏗️ 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
🚦 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
2026-05-27 22:44:18 +02:00
lance chant
eb02ac253a chore: expo 55 upgrade (#1594)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
2026-05-27 21:38:08 +02:00
renovate[bot]
1545790528 chore(deps): Lock file maintenance (#1595)
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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-27 15:38:42 +02:00
Fredrik Burmester
82eaf62354 feat(tv): fix home page loading skeletons and initialize auth/network status synchronously 2026-05-27 10:40:48 +02:00
Fredrik Burmester
05d9b8f32c style(tv): align TVSeriesSeasonCard title font size and weight on details page 2026-05-27 09:59:31 +02:00
Fredrik Burmester
df16ea9de9 style(tv): align actor name weight and role size/weight in TVActorCard 2026-05-27 09:57:12 +02:00
Fredrik Burmester
2486b2c069 style(tv): fix horizontal alignment of TVCastSection in item details page 2026-05-27 09:53:56 +02:00
Fredrik Burmester
4a4d6948cf style(tv): align actor card font sizes and horizontal padding on details page 2026-05-27 09:52:20 +02:00
Fredrik Burmester
e10255c328 fix(tv): fix horizontal padding of TVEpisodeList on item details page 2026-05-27 09:51:17 +02:00
Fredrik Burmester
08fc02a6b1 style(tv): adjust poster scale multipliers for Small and Default to align poster-to-text ratio 2026-05-27 09:46:29 +02:00
Fredrik Burmester
bba396c08c style(tv): half the top padding increase to scaleSize(145) 2026-05-27 09:45:04 +02:00
Fredrik Burmester
3316508217 style(tv): increase tvosTopPadding to 180 to offset larger posters pushing text up 2026-05-27 09:44:48 +02:00
Fredrik Burmester
5fd136ef0b style(tv): increase base poster sizes to 300, 470, and 440 2026-05-27 09:44:24 +02:00
Fredrik Burmester
2c8160188f style(tv): increase base poster sizes to 270, 430, and 400 2026-05-27 09:44:03 +02:00
Fredrik Burmester
d56d202bfc fix(tvos): increase hero carousel top padding to clear menu bar 2026-05-27 09:42:13 +02:00
Lance Chant
f1c2b9de53 fix: possible fix for edge of episode issue
possible fix(untested) to fix an issue when exiting as next episode
countdown is active

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-27 07:32:48 +02:00
renovate[bot]
11ec778bd8 chore(deps): Update github/codeql-action action to v4.36.0 (#1593)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🛡️ 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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-26 15:23:11 +02:00
Lance Chant
796e12b4ac Chore: fix the storage reading and search colour
Fixed the storage calc to be GB instead of GIB so changed to 1000
instead of 1024
Changed some of the search colours

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-26 14:24:37 +02:00
renovate[bot]
0c6ef5cbda chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1581) 2026-05-26 13:34:48 +02:00
Lance Chant
fcd19d9c6d Chore: fixing styling for plugin fiew and logs
Fixed some of the style class names to give more space for the plugin
settings
Added spacing to the bottom of the logs and removed the space at the top
for the logs

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-26 13:25:39 +02:00
Lance Chant
d72cec0f4f Chore: fixing playback controls
Fixed maybe the vertically stretched in portrait
Fixed the gesture overlay persisting after events done

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-26 13:04:19 +02:00
renovate[bot]
1e14c7ec46 chore(deps): Update actions/cache action to v5.0.5 (#1582) 2026-05-26 11:42:23 +02:00
Lance Chant
39a168456a Fixing mobile login screen
Fixed the mobile login screen layout

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-26 11:34:37 +02:00
renovate[bot]
c8ddb9a892 chore(deps): Update actions/upload-artifact action to v7 (#1584) 2026-05-26 11:20:23 +02:00
renovate[bot]
9ee71a002d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1583)
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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-26 09:40:57 +02:00
Lance Chant
5ede3f30d0 chore: more scaling fixes and selection improve
Fixed the scaling in the direct player controls to use the scaleTV
settings
Fixed 2 items in settings not being selectable (added style:flex:1)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-25 15:12:44 +02:00
Lance Chant
6b0f8b833f Chore: log cleanups, and Vo settings enablement
Added the ability to swap VO options for android only between "GPU" and
"GPU-next"
Removed some console logs from previous debugging
Added the ability to see what VO is being used to render in the video
player

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-25 14:19:36 +02:00
Lance Chant
4253f0d5ab Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-25 10:35:14 +02:00
Uruk
58f0877cfe fix(chapters): use bookmarks icon to disambiguate from episode list 2026-05-24 17:34:10 +02:00
renovate[bot]
c950408bdb chore(deps): Update dependency expo-doctor to v1.19.7 (#1578)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🛡️ 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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-24 14:00:03 +02:00
lance chant
4b577b8111 Chore/tv interface scaling (#1591)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-24 12:55:28 +02:00
Uruk
2c2a7137d3 fix(autoplay): make Cancel stop the timer and fix stale cast capture state 2026-05-23 23:36:38 +02:00
Uruk
56e350891d feat(casting): mount the autoplay watcher and countdown overlay 2026-05-23 23:27:33 +02:00
Uruk
d9e25135c4 feat(casting): add cast autoplay watcher hook 2026-05-23 23:23:54 +02:00
Uruk
84246e9dde feat(casting): add cast autoplay countdown atom 2026-05-23 23:20:08 +02:00
Uruk
57cfa5ce78 feat(casting): extract reusable next-episode helpers 2026-05-23 23:18:55 +02:00
Uruk
0ba3d19550 feat(autoplay): use AutoplayCountdown overlay in the native player 2026-05-23 23:14:33 +02:00
Uruk
58e2418120 feat(autoplay): expose countdown durations in playback settings 2026-05-23 23:13:08 +02:00
lance chant
52ad1a06e1 Chore/tv interface scaling (#1590)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-23 11:11:52 +02:00
Uruk
6c00a0348a feat(autoplay): add shared AutoplayCountdown overlay 2026-05-22 16:41:53 +02:00
lance chant
f1140927a8 chore: updated usage of tv scaling, alert text fix (#1587)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 15:28:03 +02:00
Uruk
276ba1e4c5 feat(autoplay): add configurable countdown duration settings 2026-05-22 15:14:51 +02:00
Lance Chant
03f17a758f chore: updated usage of tv scaling, alert text fix
Sweep across a few pages to ensure they use the scaling factors now
Added a plugin to fix the alert text on android tv

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 15:00:13 +02:00
Uruk
41ab4de833 fix(chapters): thinner ticks, light-grey colour on the cast bar 2026-05-22 14:30:23 +02:00
Uruk
abe4981126 chore(casting): remove DEBUG_TOUCH_ZONES overlay 2026-05-22 14:25:08 +02:00
Uruk
a9d8f753d4 fix(chapters): size chapter ticks to the slider track 2026-05-22 14:22:36 +02:00
Uruk
ee5c9ae19f fix(chapters): nudge the chapter button left of the skip controls 2026-05-22 14:13:15 +02:00
Lance Chant
5fd8e40c44 padding for apple tv
Adding some more padding for tvos to clear the top

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 12:39:59 +02:00
Lance Chant
b076f9f3d9 Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface 2026-05-22 12:39:37 +02:00
Uruk
d661a9ff7a fix(chapters): address review comments - null starts, ticksToMs, a11y, memoize 2026-05-22 12:33:57 +02:00
renovate[bot]
1ac0644a57 chore(deps): Pin expo/expo-github-action action to b184ff8 (#1580)
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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-22 12:32:51 +02:00
Uruk
4939d05e69 fix(playback): register a stable proxy controller to break a render loop 2026-05-22 12:25:32 +02:00
Uruk
7201002dd5 fix(chapters): sort chapter list entries, localize strings, fix tick keys 2026-05-22 12:10:36 +02:00
Uruk
03d2917ca0 feat(casting): chapter list button in the cast player 2026-05-22 11:59:25 +02:00
Uruk
74315a8b94 feat(casting): chapter ticks on the cast progress bar 2026-05-22 11:56:00 +02:00
Uruk
53c4f317cc feat(chapters): chapter ticks and list in the native player 2026-05-22 11:54:33 +02:00
Uruk
335a373034 feat(chapters): add ChapterList modal 2026-05-22 11:54:32 +02:00
Uruk
55595bea9b feat(chapters): add ChapterTicks slider overlay 2026-05-22 11:54:32 +02:00
Uruk
0cf6630af9 feat(chapters): add pure chapter helpers 2026-05-22 11:54:31 +02:00
lance chant
d272c6710c Fix/tv interface android (#1585)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 11:51:29 +02:00
lance chant
09bd84593c Fix/tv interface android (#1579)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 11:43:30 +02:00
Uruk
41f6116ba8 docs(casting): add autoplay+countdown design (deferred pending chapters) 2026-05-22 11:31:57 +02:00
Uruk
1e3311fea9 fix(casting): trickplay bubble positioning and mini-player preview
Position the trickplay/scrub bubble above the progress bar and let the
slider own horizontal placement (bubbleMaxWidth/bubbleWidth = tile width)
so the preview tracks the cursor and is clamped at the track edges. Wire
the mini-player trickplay to the fetched full item and size its tile/thumb.
2026-05-22 11:05:10 +02:00
Uruk
e400378684 docs(casting): mark UX player sub-project done in handoff 2026-05-22 10:07:19 +02:00
Uruk
21c0fb4b6c feat(casting): add DEBUG_TOUCH_ZONES overlay for hit-area calibration 2026-05-22 10:02:17 +02:00
Uruk
b9e87e51cc feat(casting): mini-player trickplay fix and stop button 2026-05-22 09:57:05 +02:00
Uruk
c3a9b451b6 fix(casting): clamp trickplay bubble via slider bubbleWidth 2026-05-22 09:55:23 +02:00
Uruk
418bd506c0 feat(casting): add shared CastTrickplayBubble component 2026-05-22 09:47:36 +02:00
lance chant
f8a84e34fd Fix/tv interface android (#1576)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-22 09:43:04 +02:00
Uruk
b0e92d8689 docs(casting): add player UX implementation plan 2026-05-22 09:34:38 +02:00
Uruk
4ae656818c docs(casting): add player UX (trickplay/bubble/mini-player) design spec
Fix trickplay bubble truncation via bubbleWidth, extract a shared
CastTrickplayBubble, lighten the time display, add a mini-player stop
button, and add a DEBUG_TOUCH_ZONES overlay for hand-calibrating panHitSlop.
2026-05-22 09:31:15 +02:00
Gauvain
0aa2dc5924 feat: Enables iOS TV builds (#1422) 2026-05-22 08:49:15 +02:00
Uruk
99527e1fae feat(casting): full-width labelled stop button for movies 2026-05-22 07:57:58 +02:00
Uruk
1ca6e0853b docs(casting): record player feature ideas and touch-zone note in handoff 2026-05-22 02:46:43 +02:00
Uruk
f99ce8210c feat(casting): show stop button when playing a movie 2026-05-22 02:45:20 +02:00
Uruk
674e252641 refactor: remove duplicate BitRateSheet, use shared BitrateSelector 2026-05-22 02:44:25 +02:00
Uruk
119b7ad937 refactor(casting): drop unused liveProgress export 2026-05-22 02:43:52 +02:00
Uruk
788a3b7cfd docs(casting): add chromecast refactor handoff & resume document
Captures the full state of the A/B/C/D sub-projects and the #1367 prep:
commit ranges, verification status, pending queue, key decisions, and
how to resume the work in a later session.
2026-05-22 02:32:36 +02:00
Uruk
8b94f491e4 fix(playback): dispatch each remote command once; stabilise controllers 2026-05-22 02:30:29 +02:00
Uruk
e9f61a2f7c fix(casting): guard against stale currentItem during episode load 2026-05-22 02:24:13 +02:00
Uruk
6ca1f63877 feat(casting): hide episode buttons when no adjacent episode 2026-05-22 02:22:03 +02:00
Uruk
0cc3a8469d fix(casting): report the real PlayMethod to Jellyfin 2026-05-22 02:20:51 +02:00
Uruk
b38064e2da feat(music): register music PlaybackController 2026-05-22 02:19:13 +02:00
Uruk
5b823a8efd feat(player): register native-video PlaybackController 2026-05-22 02:17:30 +02:00
Uruk
750caba038 feat(casting): register cast PlaybackController for remote control 2026-05-22 02:11:20 +02:00
Uruk
d3ee6c8239 feat(playback): handle remote-control messages over WebSocket 2026-05-22 02:07:10 +02:00
Uruk
7e2ef0f2da feat(playback): add useRemoteControl dispatch hook 2026-05-22 02:06:12 +02:00
Uruk
ca2e657eac feat(playback): add pure remote-command mapper 2026-05-22 02:05:10 +02:00
Uruk
288b390e5b feat(playback): add PlaybackController contract and registry 2026-05-22 02:03:36 +02:00
Uruk
c04924fe9e docs(casting): add session reporting & remote control plan
10-task plan for sub-project D: PlaybackController registry, pure
remote-command mapper, useRemoteControl dispatch, per-player
registration, PlayMethod fix, conditional episode buttons, loadEpisode
race fix.
2026-05-22 02:02:17 +02:00
Uruk
525a6b39fa docs(casting): add session reporting & remote control design spec
Design for sub-project D: correct PlayMethod reporting, conditional
episode buttons, loadEpisode race fix, and app-wide Jellyfin remote
control (Playstate/GeneralCommand) routed via a PlaybackController.
2026-05-22 01:58:30 +02:00
renovate[bot]
e7f200a114 chore(deps): Update dependency i18next to v26 (#1573)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-22 01:32:27 +02:00
Uruk
1ea7f0f491 refactor(casting): extract useCastPlayerProgress hook 2026-05-22 01:32:21 +02:00
Uruk
79c2829444 refactor(casting): extract useCastDismissGesture hook 2026-05-22 01:22:21 +02:00
Uruk
87e0b0006b refactor(casting): extract useCastEpisodes hook 2026-05-22 01:18:08 +02:00
Uruk
3c71c08591 refactor(casting): extract useCastPlayerItem hook 2026-05-22 01:15:29 +02:00
Uruk
9f4f0fa7d1 refactor(casting): extract CastPlayerTransportControls 2026-05-22 01:10:30 +02:00
Uruk
0d922b75d6 refactor(casting): extract CastPlayerProgressBar 2026-05-22 01:08:04 +02:00
renovate[bot]
da9afacbf7 chore(deps): Update dependency react-i18next to v17 (#1574) 2026-05-22 01:07:52 +02:00
Uruk
0ee1d43d16 refactor(casting): extract CastPlayerEpisodeControls 2026-05-22 01:02:37 +02:00
Uruk
ec49d03cf1 refactor(casting): extract CastPlayerPoster 2026-05-22 00:59:57 +02:00
Uruk
02df2477d8 refactor(casting): extract CastPlayerHeader and CastPlayerTitle 2026-05-22 00:57:55 +02:00
Uruk
8c9506c7b5 docs(casting): add casting-player split implementation plan
10-task plan for sub-project C: extract 6 presentational components +
4 hooks from casting-player.tsx, leaving a thin orchestrator. Purely
mechanical, behaviour-neutral.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:56:21 +02:00
Uruk
b225286f57 docs(casting): add casting-player split design spec 2026-05-22 00:53:17 +02:00
Uruk
23b4f20d18 fix(casting): track menus from full item, keep quality on version switch, stable resume position
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 00:05:14 +02:00
Uruk
88d96603e4 feat(casting): reliable track switching with CastSelection truth 2026-05-21 23:58:22 +02:00
Uruk
6e513b8f9e feat(player): expand shared BITRATES ladder 2026-05-21 23:50:21 +02:00
Uruk
4f50ec6665 feat(casting): add useCastSelection hook 2026-05-21 23:49:23 +02:00
Uruk
0e25a5936c feat(casting): resolve and embed full CastSelection on load 2026-05-21 23:48:29 +02:00
Uruk
e9fee79130 feat(casting): embed CastSelection in cast customData 2026-05-21 23:47:23 +02:00
Uruk
3d65c3bb7a feat(casting): add CastSelection model and resolution helpers 2026-05-21 23:46:19 +02:00
Uruk
e5d61bf3ea docs(casting): add track switching implementation plan
6-task plan for sub-project B: CastSelection model, customData source of
truth, useCastSelection hook, expanded BITRATES, casting-player and
settings-menu rework.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:44:05 +02:00
Uruk
5eac91190e docs(casting): quality filter by media bitrate + device cap
Per review: filter quality tiers by both ceilings; correct the shared
BITRATES file reference to BitrateSelector.tsx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:43:44 +02:00
Uruk
95d63e3c8a docs(casting): quality menu reuses expanded shared BITRATES
Per review: the cast quality selector reuses the app-wide BITRATES
constant (expanded to the Jellyfin Android TV ladder) instead of a
cast-specific list, filtered by device capability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:30:18 +02:00
Uruk
6d0ca44308 docs(casting): add track switching & multi-version design spec
Design for sub-project B: a single CastSelection source of truth carried
in cast customData (approach A3), fixing audio/subtitle/quality desync,
plus a real multi-version selector.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:23:19 +02:00
Lance Chant
cc89b5df89 fix: a few playback and home recommendation issues
Fixed the home recommendation display to use proper images.
Fixed an issue for the subtitles background rendering
Fixed playback resume from position

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-21 18:04:45 +02:00
Lance Chant
d1794798e7 Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface 2026-05-21 13:56:04 +02:00
Steve Byatt
11a4f14732 feat(android-tv): TV recommendations (#1575) 2026-05-21 13:55:26 +02:00
Steve Byatt
121ff0eea0 feat(tvos): Add TopShelf Extension (#1561) 2026-05-21 08:47:45 +02:00
Uruk
73214f5d45 fix(casting): apply conservative bitrate cap on downgrade retry
The status-2100 downgrade retry overrode only the device profile's
MaxStreamingBitrate. Jellyfin uses the explicit getStreamUrl maxBitrate
request param as the effective ceiling, so a quality-menu bitrate would
survive the retry. Clamp options.maxBitrate to the fallback cap too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:36:25 +02:00
Uruk
5cfd110ad5 docs(casting): add Chromecast cast test matrix 2026-05-21 02:33:12 +02:00
Uruk
6e63afc61a feat(casting): replace H265 toggle with Chromecast profile selector 2026-05-21 02:32:18 +02:00
renovate[bot]
80fdd579f3 chore(deps): Update dependency @react-native-community/cli to v20.1.3 (#1571)
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
🔒 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 / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor 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
2026-05-21 02:28:38 +02:00
Uruk
bcf6b705e1 refactor(casting): route all cast loads through loadCastMedia
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:27:20 +02:00
Uruk
fb8c649f6f feat(casting): add unified loadCastMedia with downgrade-on-failure 2026-05-21 02:20:06 +02:00
Uruk
6ecadecb87 feat(casting): add chromecastProfile and chromecastMaxBitrate settings 2026-05-21 02:17:59 +02:00
Uruk
e3f105691b feat(casting): add Chromecast device profile builder 2026-05-21 02:16:29 +02:00
Uruk
bcfa8c6d63 feat(casting): add Chromecast capability detection 2026-05-21 02:13:31 +02:00
Uruk
17450e3811 docs(casting): add device profiles implementation plan
7-task TDD plan for sub-project A: capability detection, profile builder,
unified loadCastMedia, call-site rewiring, settings UI, test matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 02:09:44 +02:00
Uruk
9759d84aa2 docs(casting): drop settings migration from profiles design
Per review: break enableH265ForChromecast outright instead of carrying
migration code. Users reconfigure; chromecastProfile defaults to auto.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:46:57 +02:00
Uruk
28d8b28c73 docs(casting): add device profiles & capability detection design spec
Design for sub-project A of the Chromecast refactor: per-device capability
detection, profile builder replacing the two static profiles, unified cast
load, and load-path bug fixes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:42:04 +02:00
Uruk
a4e47e5cb7 feat(casting): improve track selection and session handling
implements automatic initialization of audio and subtitle tracks based on server-provided defaults. ensures subtitle selection persists correctly during stream reloads by resolving track indices more reliably.

replaces crypto-based uuid generation with a math-based fallback to ensure compatibility with environments lacking global crypto support. adds missing media source metadata to cast info to improve consistency between the client and receiver.
2026-05-21 00:48:37 +02:00
Uruk
fcd7e46599 fix: resolve 13 review issues across casting components
- casting-player: remove redundant self-navigation useEffect
- casting-player: derive Type from metadata instead of hardcoding 'Movie'
- casting-player: pass null to useTrickplay instead of empty BaseItemDto
- casting-player: use != null for skip time labels (allow 0 to render)
- Chromecast: case-insensitive m3u8 detection via regex
- Chromecast: fix UUID hyphen indices to 4,6,8,10 for proper v4 format
- CastingMiniPlayer: use SeriesPrimaryImageTag for series poster URL
- ChromecastConnectionMenu: send rounded volume to castSession.setVolume
- ChromecastConnectionMenu: use isMutedRef in onValueChange to avoid stale closure
- ChromecastDeviceSheet: skip volume sync during active sliding
- ChromecastDeviceSheet: move unmute logic from onValueChange to onSlidingStart
- useCasting: detect playback start via isPlaying/playerState, not just progress>0
- useCasting: derive isChromecastAvailable from castState instead of hardcoding true
- useTrickplay: accept BaseItemDto|null with null guards on Id access
2026-05-21 00:47:31 +02:00
Uruk
a841619d78 Fix: Generates UUID v4 for play session ID
Fixes the play session ID generation to use UUID v4 format.

This ensures a more robust and standard identifier for tracking play sessions.
2026-05-21 00:47:31 +02:00
Uruk
6c72a2803f fix(chromecast): replace Math.random with crypto.getRandomValues for session ID 2026-05-21 00:47:31 +02:00
Uruk
6bf00abb9b fix: Refactors casting player and components
Refactors the casting player screen and related components for improved code clarity, performance, and maintainability.

- Removes unused code and simplifies logic, especially around audio track selection and recommended stereo track handling.
- Improves the formatting of trickplay time displays for consistency.
- Streamlines UI elements and removes unnecessary conditional checks.
- Updates the Chromecast component to use hooks for side effects, ensuring the Chromecast session remains active.
- Improves the display of the language in the audio track display.
2026-05-21 00:47:31 +02:00
Uruk
ac405af3b2 Fix: Improves Chromecast casting experience
Fixes several issues and enhances the Chromecast casting experience:

- Prevents errors when loading media by slimming down the customData payload to avoid exceeding message size limits.
- Improves logic for selecting custom data from media status.
- Fixes an issue with subtitle track selection.
- Recommends stereo audio tracks for better Chromecast compatibility.
- Improves volume control and mute synchronization between the app and the Chromecast device.
- Adds error handling for `loadMedia` in `PlayButton`.
- Fixes image caching issue for season posters in mini player.
- Implements cleanup for scroll retry timeout in episode list.
- Ensures segment skipping functions are asynchronous.
- Resets `hasReportedStartRef` after stopping casting.
- Prevents seeking past the end of Outro segments.
- Reports playback progress more accurately by also taking player state changes into account.
2026-05-21 00:47:31 +02:00
Uruk
9ec81cfa1d Fix: Improves Chromecast casting experience
Fixes several issues and improves the overall Chromecast casting experience:

- Implements an AbortController for fetching item data to prevent race conditions.
- Syncs live progress in the mini player more accurately using elapsed real time.
- Prevents event propagation in the mini player's play/pause button.
- Ensures the disconnect callback in the connection menu is always called.
- Retries scrolling in the episode list on failure.
- Handles unmute failures gracefully in volume controls.
- Clamps seek positions to prevent exceeding duration.
- Fixes reporting playback start multiple times
- Improves segment calculation in `useChromecastSegments`
- Prevents race condition with `isPlaying` state in `Controls` component

Also includes minor UI and timing adjustments for a smoother user experience.
2026-05-21 00:47:31 +02:00
Uruk
28bf1489c1 Fix: Improve casting and segment skipping
Fixes several issues and improves the casting player experience.

- Adds the ability to disable segment skipping options based on plugin settings.
- Improves Chromecast integration by:
  - Adding PlaySessionId for better tracking.
  - Improves audio track selection
  - Uses mediaInfo builder for loading media.
  - Adds support for loading next/previous episodes
  - Translation support
- Updates progress reporting to Jellyfin to be more accurate and reliable.
- Fixes an error message in the direct player.
2026-05-21 00:47:31 +02:00
Uruk
68d64fec9c feat: Enhances casting player with trickplay
Implements trickplay functionality with preview images to improve the casting player's seeking experience.

Adds a progress slider with trickplay preview, allowing users to scrub through media with visual feedback. Integrates device volume control and mute functionality to the Chromecast device sheet. Also fixes minor bugs and improves UI.
2026-05-21 00:47:31 +02:00
Uruk
9dcbcdc41d fix: Refactors Chromecast casting player
Refactors the Chromecast casting player for better compatibility, UI improvements, and stability.

- Adds auto-selection of stereo audio tracks for improved Chromecast compatibility
- Refactors episode list to filter out virtual episodes and allow season selection
- Improves UI layout and styling
- Removes connection quality indicator
- Fixes progress reporting to Jellyfin
- Updates volume control to use CastSession for device volume
2026-05-21 00:47:31 +02:00
Uruk
99775b353f feat: Enhances casting player with API data
Enriches the casting player screen by fetching item details from the Jellyfin API for a more reliable and complete user experience.

The casting player now prioritizes item data fetched directly from the API, providing richer metadata and ensuring accurate information display.

- Fetches full item data based on content ID.
- Uses fetched data as the primary source of item information, falling back to customData or minimal info if unavailable.
- Improves UI by showing connection quality and bitrate.
- Enhances episode list display and scrolling.
- Adds a stop casting button.
- Minor UI adjustments for better readability and aesthetics.

This change enhances the accuracy and reliability of displayed information, improving the overall user experience of the casting player.
2026-05-21 00:47:31 +02:00
Uruk
7589ccd284 feat: Enhance Chromecast functionality and UI improvements
- Implemented a retry mechanism for Chromecast device discovery with a maximum of 3 attempts.
- Added logging for discovered devices to aid in debugging.
- Updated Chromecast button interactions to streamline navigation to the casting player.
- Changed the color scheme for Chromecast components to a consistent purple theme.
- Modified the ChromecastDeviceSheet to sync volume slider with prop changes.
- Improved the ChromecastSettingsMenu to conditionally render audio and subtitle tracks based on availability.
- Updated translations for the casting player to include new strings for better user experience.
2026-05-21 00:47:31 +02:00
Uruk
d4f730fc54 fix: use full route path for casting-player navigation 2026-05-21 00:47:31 +02:00
Uruk
e002381706 debug: add logging to Chromecast button tap handler 2026-05-21 00:47:31 +02:00
Uruk
838a248d28 fix: handle casting-player navigation when no back stack exists
Use useEffect to check connection state and redirect properly. If no back
stack exists, navigate to home tab instead of calling router.back().
2026-05-21 00:47:31 +02:00
Uruk
a5c72011a8 fix: route Chromecast button to custom casting-player instead of native UI 2026-05-21 00:47:31 +02:00
Uruk
51bd8a92da refactor(casting): remove AirPlay references, keep extensible architecture
- Remove AirPlay from CastProtocol type union (Chromecast only for now)
- Replace AirPlay TODOs with generic 'Future: Add X for other protocols' comments
- Remove PROTOCOL_COLORS export, use hardcoded Chromecast color (#F9AB00)
- Update all component headers to be protocol-agnostic
- Keep switch statements extensible for future protocol additions
- Maintain clean architecture for easy integration of new casting protocols

Architecture remains flexible for future protocols (AirPlay, DLNA, etc.)
2026-05-21 00:47:31 +02:00
Uruk
515e05015f chore: remove unnecessary AirPlay documentation 2026-05-21 00:47:31 +02:00
Uruk
7126564f72 feat(casting): complete all remaining TODOs
- Expose RemoteMediaClient from useCasting for advanced operations
- Implement episode fetching from Jellyfin API for TV shows
- Add next episode detection with countdown UI showing episode name
- Wire audio/subtitle track changes to RemoteMediaClient.setActiveTrackIds
- Wire playback speed to RemoteMediaClient.setPlaybackRate
- Add tap-to-seek functionality to progress bar
- Update segment skip buttons to use remoteMediaClient seek wrapper
- Create comprehensive AirPlay implementation documentation

All casting system features are now complete before PR submission.
2026-05-21 00:47:31 +02:00
Uruk
6894decdba feat: optimize and complete casting system implementation
Performance Optimizations:
- Add progress tracking to skip redundant Jellyfin API calls (< 5s changes)
- Debounce volume changes (300ms) to reduce API load
- Memoize expensive calculations (protocol colors, icons, progress percent)
- Remove dead useChromecastPlayer hook (redundant with useCasting)

Feature Integrations:
- Add ChromecastEpisodeList modal with Episodes button for TV shows
- Add ChromecastDeviceSheet modal accessible via device indicator
- Add ChromecastSettingsMenu modal with settings icon
- Integrate segment detection with Skip Intro/Credits/Recap buttons
- Add next episode countdown UI (30s before end)

AirPlay Support:
- Add comprehensive documentation for AirPlay detection approaches
- Document integration requirements with AVRoutePickerView
- Prepare infrastructure for native module or AVPlayer integration

UI Improvements:
- Make device indicator tappable to open device sheet
- Add settings icon in header
- Show segment skip buttons dynamically based on current playback
- Display next episode countdown with cancel option
- Optimize hook ordering to prevent conditional hook violations

TODOs for future work:
- Fetch actual episode list from Jellyfin API
- Wire media source/audio/subtitle track selectors to player
- Implement episode auto-play logic
- Create native module for AirPlay state detection
- Add RemoteMediaClient to segment skip functions
2026-05-21 00:47:31 +02:00
Uruk
72c050b9a5 refactor: clean up dead code and consolidate casting utilities
- Remove dead Chromecast files (ChromecastMiniPlayer, chromecast-player)
- Remove dead AirPlay files (AirPlayMiniPlayer, airplay-player, useAirPlayPlayer)
- Remove duplicate AirPlay utilities (options.ts, helpers.ts)
- Consolidate unique Chromecast helpers into unified casting helpers
  - Add formatEpisodeInfo and shouldShowNextEpisodeCountdown
- Update all imports to use unified casting utilities
- Fix TypeScript errors:
  - Use correct MediaStatus properties (playerState vs isPaused/isBuffering)
  - Use getPlaystateApi from Jellyfin SDK
  - Use setStreamVolume for RemoteMediaClient
  - Fix calculateEndingTime signature
  - Fix segment auto-skip to use proper settings (skipIntro, skipOutro, etc)
  - Remove unused imports
- Update ChromecastSettingsMenu to use unified types from casting/types.ts
2026-05-21 00:47:31 +02:00
Uruk
1da3d7cfc6 feat(casting): unify Chromecast and AirPlay into single casting interface
BREAKING CHANGE: Merged separate Chromecast and AirPlay implementations into unified casting system

- Created unified casting types and helpers (utils/casting/)
- Built useCasting hook that manages both protocols
- Single CastingMiniPlayer component works with both Chromecast and AirPlay
- Single casting-player modal for full-screen controls
- Protocol-aware UI: Red for Chromecast, Blue for AirPlay
- Shows device type icon (TV for Chromecast, Apple logo for AirPlay)
- Detects active protocol automatically
- Previous separate implementations (ChromecastMiniPlayer, AirPlayMiniPlayer) superseded

Benefits:
- Better UX: One cast button shows all available devices
- Cleaner architecture: Protocol differences abstracted
- Easier maintenance: Single UI codebase
- Protocol-specific logic isolated in adapters
2026-05-21 00:47:31 +02:00
Uruk
594a1d04aa feat(airplay): add complete AirPlay support for iOS
- Created AirPlay utilities (options, helpers)
- Built useAirPlayPlayer hook for state management
- Created AirPlayMiniPlayer component (bottom bar when AirPlaying)
- Built full AirPlay player modal with gesture controls
- Integrated AirPlay mini player into app layout
- iOS-only feature using native AVFoundation/ExpoAvRoutePickerView
- Apple-themed UI with blue accents (#007AFF)
- Supports swipe-down to dismiss
- Shows device name, progress, and playback controls
2026-05-21 00:47:31 +02:00
Uruk
9efe12637b feat(chromecast): add modal components and integrate autoskip API 2026-05-21 00:47:31 +02:00
Uruk
9398f5f104 feat(chromecast): integrate autoskip segments into chromecast player
- Merge autoskip branch with segment detection
- Update useChromecastSegments to use real segment API
- Support intro, credits, recap, commercial, and preview segments
- Add auto-skip support based on user settings
- Ready for full testing
2026-05-21 00:47:31 +02:00
Uruk
18600a4956 refactor: optimize segment handling with useMemo and improve skip function fallback 2026-05-21 00:47:31 +02:00
Uruk
e54bdd048b feat: add skip credit button text localization to BottomControls and Controls 2026-05-21 00:47:31 +02:00
Uruk
e35623c46c refactor: remove unused Segment interface from MediaTimeSegment 2026-05-21 00:47:31 +02:00
Uruk
25730a24d6 fix: update dependencies in skipSegment callback for accurate state tracking 2026-05-21 00:47:31 +02:00
Uruk
5a1fe51ad7 fix: handle null settings in useSkipOptions for safer access 2026-05-21 00:47:31 +02:00
Uruk
8dc0421e22 feat: add timeout management for playback to prevent race conditions 2026-05-21 00:47:31 +02:00
Uruk
4125924aa6 fix: correct order of segment skip options in settings 2026-05-21 00:47:31 +02:00
Uruk
eeb27cbaf6 refactor: move player translations to common section
Relocates player-specific translation keys from the "player" namespace to the "common" namespace to improve reusability across different components.

All player-related strings (error messages, playback controls, download prompts) are now accessible as common translations, enabling their use throughout the application without namespace-specific imports.
2026-05-21 00:47:31 +02:00
Uruk
88b79920bf feat: add i18n support for skip button text
- Add player.skip_* translation keys for all 5 segment types
- Enable proper localization of skip button text
- Addresses GitHub Copilot review comment
2026-05-21 00:47:31 +02:00
Uruk
203c6d59b0 refactor: address GitHub Copilot review comments
- Remove unnecessary currentSegment from skipSegment dependency array
- Remove redundant wrappedSeek wrapper (ref guard prevents issues)
- Document 200ms setTimeout delay for seek operations
- Improve code clarity and reduce unnecessary re-renders
2026-05-21 00:47:31 +02:00
Uruk
bb491d4e86 refactor: apply CodeRabbit suggestions for segment skip feature
- Add missing segment types (recap, commercial, preview) to JobStatus
- Consolidate duplicate useMemo blocks with factory function
- Improve code maintainability and consistency
2026-05-21 00:47:31 +02:00
Uruk
0b6639fc4e feat: add comprehensive segment skip with all 5 types and settings submenu
- Add SegmentSkipMode type ('none', 'ask', 'auto') in settings.ts
- Create 5 segment skip settings: skipIntro, skipOutro, skipRecap, skipCommercial, skipPreview
- Update segments.ts to fetch all 5 segment types from Jellyfin MediaSegments API (10.11+)
- Create unified useSegmentSkipper hook supporting all segment types with 3 modes
- Update video player Controls.tsx with priority system (Commercial > Recap > Intro > Preview > Outro)
- Add dynamic skip button text in BottomControls.tsx
- Create dedicated settings submenu at settings/segment-skip/page.tsx
- Simplify PlaybackControlsSettings.tsx with navigation to submenu
- Extend DownloadedItem interface with all segment types for offline support
- Add 13+ translation keys for segment skip UI
2026-05-21 00:47:31 +02:00
Uruk
71d922beeb fix(chromecast): resolve TypeScript errors and improve type safety
- Fix deviceName property to use friendlyName
- Update disconnect to use stop() instead of endSession()
- Fix null handling in getPosterUrl and useTrickplay
- Remove unused variables and imports
- Add proper null checks in segment skipping
- Disable auto-skip until settings are available
2026-05-21 00:47:31 +02:00
Uruk
5e60b6c2f8 feat(chromecast): add new player UI with mini player, hooks, and utilities
- Create ChromecastMiniPlayer component (bottom bar navigation)
- Create chromecast-player modal route with full UI
- Add useChromecastPlayer hook (playback controls & state)
- Add useChromecastSegments hook (intro/credits/segments)
- Add chromecast options (constants & config)
- Add chromecast helpers (time formatting, quality checks)
- Implement swipe-down gesture to dismiss
- Add Netflix-style buffering indicator
- Add progress tracking with trickplay support
- Add next episode countdown
- Ready for segments integration from autoskip branch
2026-05-21 00:47:31 +02:00
Uruk
da70541c8e fix: add Chromecast video progress tracking 2026-05-21 00:47:31 +02:00
renovate[bot]
f79cf1925d chore(deps): Update dependency @react-native-tvos/config-tv to v0.1.6 (#1430) 2026-05-21 00:18:29 +02:00
renovate[bot]
8bb0d845a2 chore(deps): Update dependency lint-staged to v17 (#1570) 2026-05-20 23:29:39 +02:00
renovate[bot]
c7cd8217c9 chore(deps): Update dependency react-i18next to v16.5.8 (#1500)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 23:03:35 +02:00
renovate[bot]
235ba1473f chore(deps): Update dependency lint-staged to v16.4.0 (#1507) 2026-05-20 22:40:20 +02:00
renovate[bot]
284a4e3d41 chore(deps): Update dependency expo-doctor to v1.18.22 (#1506) 2026-05-20 19:10:36 +02:00
renovate[bot]
1fd3574520 chore(deps): Update actions/github-script action to v9 (#1568) 2026-05-20 18:36:38 +02:00
renovate[bot]
f1188c090a chore(deps): Update peter-evans/create-pull-request action to v8.1.1 (#1566) 2026-05-20 17:37:53 +02:00
renovate[bot]
1321a5c000 chore(deps): Update actions/dependency-review-action action to v5 (#1567) 2026-05-20 17:05:10 +02:00
Lance Chant
3d18312903 Merge branch 'feat/tv-interface' of https://github.com/streamyfin/streamyfin into feat/tv-interface 2026-05-20 15:31:08 +02:00
Steve Byatt
4bef386b82 fix(tvOS): Patches for udp and screens menu button handling (#1564) 2026-05-20 15:30:40 +02:00
lance chant
e84cea6427 chore: cleanup (#1565) 2026-05-20 15:29:24 +02:00
renovate[bot]
52bc5e912d chore(deps): Update actions/dependency-review-action action to v4.9.0 (#1502) 2026-05-20 15:28:34 +02:00
Lance Chant
023bd15ca2 chore: cleanup
Cleaning up some old console logs
Fixed the collection view to include seasons to align with the server
view

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 15:04:15 +02:00
renovate[bot]
7bccafc476 chore(deps): Update actions/dependency-review-action action to v4.8.3 (#1499)
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
🔒 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 / 🔍 Lint & Test (lint) (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 (typecheck) (push) Has been cancelled
2026-05-20 14:49:24 +02:00
renovate[bot]
8df61838d4 chore(deps): Update actions/cache action to v5.0.5 (#1429) 2026-05-20 13:43:17 +02:00
renovate[bot]
55776d887f chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1498) 2026-05-20 13:17:20 +02:00
renovate[bot]
2e7079cb5a chore(deps): Update github/codeql-action action to v4.35.5 (#1508) 2026-05-20 12:54:50 +02:00
Steve Byatt
a1c98f9285 fix: tv overlay focus navigation (#1558) 2026-05-20 12:53:01 +02:00
Steve Byatt
ca4f24ded0 fix: handle TV menu and back navigation (#1559) 2026-05-20 09:57:19 +02:00
lance chant
92deba14f3 Adding QR code login (#1557)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 08:41:49 +02:00
lance chant
ece5750d34 Feat/tv interface uniform scale (#1562)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-05-20 07:56:39 +02:00
renovate[bot]
428455f6a6 chore(deps): Update actions/setup-node action to v6.4.0 (#1503)
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
🔒 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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 01:08:17 +02:00
renovate[bot]
8c749cdc4d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1509) 2026-05-20 00:44:45 +02:00
renovate[bot]
7ed0c00ce7 chore(deps): Update actions/upload-artifact action to v7 (#1510) 2026-05-20 00:24:58 +02:00
renovate[bot]
222ae69644 chore(deps): Update marocchino/sticky-pull-request-comment action to v3 (#1512) 2026-05-20 00:03:36 +02:00
renovate[bot]
fec8df37f7 chore(deps): Pin dependencies (#1537) 2026-05-19 23:40:40 +02:00
renovate[bot]
0e0e722e1c chore(deps): Update crowdin/github-action action to v2.16.2 (#1504)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-19 19:58:56 +02:00
Théo FORTIN
2ce810c191 fix(player/ios): Disable subtitles embedding for iOS simulator (#1544)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-04-24 10:44:30 +02:00
Alex Collado
564a593a3a fix: Close modal when back button is pressed on Android (#1487)
Some checks failed
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (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
🔒 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 / 🔍 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
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-03-12 13:45:17 +01:00
Fredrik Burmester
3438e78cab feat(tv): implement edge-to-edge horizontal sections for apple tv-like experience 2026-02-01 22:04:53 +01:00
Fredrik Burmester
67bca1f989 refactor(tv): have section pages fill width 2026-02-01 21:10:25 +01:00
Fredrik Burmester
c35e97f388 feat(tv): persist downloaded opensubtitles across app restarts 2026-02-01 19:19:32 +01:00
Fredrik Burmester
bc575c26c1 feat(mpv): add opaque subtitle background with adjustable opacity (iOS only) 2026-02-01 17:29:31 +01:00
Fredrik Burmester
ab526f2c6b chore(tv): suppress tvOS hover gesture warning 2026-02-01 16:39:04 +01:00
Fredrik Burmester
7d0b3be8c2 chore(tv): remove debug logs from back handler 2026-02-01 16:27:32 +01:00
Fredrik Burmester
a384b34402 chore: translations 2026-02-01 15:52:54 +01:00
Fredrik Burmester
07f535a6e4 fix: no log 2026-02-01 15:52:26 +01:00
Fredrik Burmester
2bcf52209e refactor(tv): remove native tv-player-controls module usage 2026-02-01 14:38:35 +01:00
Fredrik Burmester
fb7cee7718 fix(tv): improve skip/countdown focus and back button handling 2026-02-01 14:03:20 +01:00
Fredrik Burmester
2775075187 docs: add settings atom and translation key guidelines 2026-02-01 13:03:47 +01:00
Fredrik Burmester
4962f2161f refactor(tv): remove auto-scroll behaviors from search and series 2026-02-01 12:44:36 +01:00
Fredrik Burmester
25ec9c4348 fix(tv): remove automatic scroll triggers on series page 2026-02-01 12:39:05 +01:00
Fredrik Burmester
d17414bc93 fix(auth): distinguish session expiry from network errors 2026-02-01 12:27:22 +01:00
Fredrik Burmester
fea3e1449a fix(player): add null check for api in direct-player 2026-01-31 23:43:05 +01:00
Fredrik Burmester
ad1d9b5888 fix(tv): pause inactivity timer during video playback 2026-01-31 23:33:11 +01:00
Fredrik Burmester
3d406314a4 feat(tv): add configurable inactivity timeout with auto-logout 2026-01-31 22:29:39 +01:00
Fredrik Burmester
e6598f0944 perf(tv): optimize focus animations and disable native glass effect 2026-01-31 21:34:49 +01:00
Fredrik Burmester
f549e8eaed feat(tv): reorder series page buttons to center season selector 2026-01-31 21:03:54 +01:00
Fredrik Burmester
dab1c10a03 fix(tv): use single value for user-management entitlement 2026-01-31 19:55:22 +01:00
Fredrik Burmester
7e2962e539 fix(tv): remove deprecated presentUserPicker API 2026-01-31 18:59:30 +01:00
Fredrik Burmester
81cf672eb7 fix: convert native.js to native.ts and fix imports for EAS builds
- Convert utils/profiles/native.js to TypeScript
- Add barrel export index.ts for profiles
- Update all imports to use explicit file paths instead of barrel export
- Fix .gitignore to only ignore root-level profiles/ directory
2026-01-31 18:54:23 +01:00
Fredrik Burmester
591d89c19f feat(tv): local build eas creds 2026-01-31 17:52:35 +01:00
Fredrik Burmester
44b7434cdd refactor(tv): simplify user profile management with automatic sandboxing 2026-01-31 17:28:15 +01:00
Fredrik Burmester
717186e13e fix(tv): set node version 2026-01-31 15:00:38 +01:00
Fredrik Burmester
4afab8d94a fix(mpv): pause playback when tvOS app enters background 2026-01-31 13:42:52 +01:00
Fredrik Burmester
4601ae20b6 fix(tv): stop quick connect polling on login page exit 2026-01-31 12:58:44 +01:00
Fredrik Burmester
1ec887c29e feat(tv): new login design 2026-01-31 11:49:15 +01:00
Fredrik Burmester
85a74a9a6a refactor: login page 2026-01-31 10:52:21 +01:00
Fredrik Burmester
6e85c8d54a feat(tv): add user switching from settings 2026-01-31 09:53:54 +01:00
Fredrik Burmester
bf518b4834 fix(tv): poster images 2026-01-30 20:45:00 +01:00
Fredrik Burmester
d78ac2963f feat(tv): add language selector 2026-01-30 19:38:25 +01:00
Fredrik Burmester
2818c17e97 chore: sv translations 2026-01-30 19:16:53 +01:00
Fredrik Burmester
b87e7a159f fix(tv): home screen sections not loading 2026-01-30 19:09:31 +01:00
Fredrik Burmester
af2cac0e86 feat(player): add skip intro/credits support for tvOS 2026-01-30 18:52:22 +01:00
Fredrik Burmester
28e3060ace feat(player): add chapter navigation support with visual markers 2026-01-30 18:02:32 +01:00
Fredrik Burmester
3814237ac6 fix(glass-poster): prevent image overflow on tvOS 26 2026-01-30 09:16:01 +01:00
Fredrik Burmester
aed3a8f493 fix(tv): poster design and other stuff 2026-01-30 09:15:44 +01:00
Fredrik Burmester
0cd74519d4 fix(tv): correct episode image priority and scale animation in posters 2026-01-30 09:15:01 +01:00
Fredrik Burmester
8ecb7c205b feat(tv): add smart back button handler to prevent unwanted app exit 2026-01-29 21:53:45 +01:00
Fredrik Burmester
3827350ffd feat(tv): add focus management to next episode countdown card 2026-01-29 18:22:28 +01:00
Fredrik Burmester
53902aebab feat(tv): change playback options layout to horizontal row 2026-01-29 18:17:43 +01:00
Fredrik Burmester
bf3a37c61c feat(player): change technical info button icon to code-slash 2026-01-29 18:05:53 +01:00
Fredrik Burmester
2c0a9b6cd9 feat(tv): migrate login to white design with navigation modals 2026-01-29 12:12:20 +01:00
Fredrik Burmester
80136f1800 feat(tv): enable video playlists library with square thumbnail grid 2026-01-29 07:38:56 +01:00
Fredrik Burmester
01298c9b6d chore(i18n): add no_results translation key to common section 2026-01-29 07:32:13 +01:00
Fredrik Burmester
4bea01c963 fix(tv): prevent theme music from playing twice on shared transitions 2026-01-29 07:31:34 +01:00
Fredrik Burmester
94ac458f52 refactor(tv): use shared components and proper typography in actor page 2026-01-28 22:51:35 +01:00
Fredrik Burmester
409629bb4a feat(tv): add background theme music playback 2026-01-28 22:08:32 +01:00
Fredrik Burmester
2ff9625903 feat(tv): add long-press mark as watched action using alert dialog 2026-01-28 20:36:57 +01:00
Fredrik Burmester
8dcd4c40f9 chore: remove debug console.log statements from providers and layout 2026-01-28 20:21:56 +01:00
Fredrik Burmester
74114893e5 fix(tv): use router.replace for episode navigation to prevent page stacking 2026-01-28 19:57:54 +01:00
Fredrik Burmester
268a6d96de refactor(tv): swap poster and content layout in series page 2026-01-28 19:50:12 +01:00
Fredrik Burmester
2780b902e9 feat(tv): add favorite button to series detail page 2026-01-28 19:47:47 +01:00
Fredrik Burmester
6033958158 refactor(claude): restructure learned facts into individual files with compressed index 2026-01-28 19:45:51 +01:00
Fredrik Burmester
9763c26046 fix(player): handle remote streams and live tv containers correctly 2026-01-28 19:40:18 +01:00
Fredrik Burmester
05a2627c94 fix(auth): make logout device cleanup fire-and-forget 2026-01-28 19:31:20 +01:00
Fredrik Burmester
62a099e82f refactor(player): consolidate subtitle settings to use mpvSubtitleScale only 2026-01-26 21:01:25 +01:00
Fredrik Burmester
43ca6e9148 fix(player): disable subtitle scaling with window on iOS 2026-01-26 20:50:16 +01:00
Fredrik Burmester
1cbb46f0ca feat(player): add mpv cache and buffer configuration 2026-01-26 20:46:42 +01:00
Fredrik Burmester
21f2ceefc3 feat(tv): live tv 2026-01-26 20:30:50 +01:00
Fredrik Burmester
9d6a9decc9 style(tv): match live tv header styling to home tab 2026-01-26 19:59:30 +01:00
Fredrik Burmester
246e0af0f6 feat(tv): improve live tv guide scrolling and time range 2026-01-26 19:53:13 +01:00
Fredrik Burmester
a0dd752d8f feat(tv): add channels tab with direct channel playback and live tv controls 2026-01-26 19:42:10 +01:00
Fredrik Burmester
c5eb7b0c96 feat(tv): live tv initial commit 2026-01-26 19:41:03 +01:00
Fredrik Burmester
55c74ab383 feat(player): enable language-based track selection on mobile 2026-01-26 19:36:51 +01:00
Fredrik Burmester
7fe24369c0 feat(tv): add language-based audio and subtitle track selection 2026-01-26 19:32:06 +01:00
Fredrik Burmester
111397a306 refactor(tv): extract TVEpisodeList component to reduce code duplication 2026-01-26 19:17:29 +01:00
Fredrik Burmester
b79b343ce3 refactor(tv): replace poster multiplier scaling with linear offset-based scaling 2026-01-26 18:36:44 +01:00
Fredrik Burmester
c029228138 feat(tv): add now playing badge to current episode in season list 2026-01-26 18:15:52 +01:00
Fredrik Burmester
d51cf47eb4 feat(tv): add scalable poster sizes synchronized with typography settings 2026-01-26 18:04:22 +01:00
Fredrik Burmester
bbd7854287 fix(tv): resolve home sections not rendering when hero carousel is enabled 2026-01-26 18:03:18 +01:00
Fredrik Burmester
44caf4b1ff feat(i18n): add swedish translations for tv interface and fix hardcoded strings 2026-01-26 08:17:00 +01:00
Fredrik Burmester
92c70fadd1 refactor(tv): reorganize item detail page layout and improve episode list 2026-01-26 08:16:59 +01:00
Fredrik Burmester
f637367b82 style(tv): update episode section heading typography and spacing 2026-01-26 07:51:55 +01:00
Fredrik Burmester
715764cef8 feat(tv): add season episode list to episode detail page 2026-01-25 23:23:03 +01:00
Fredrik Burmester
36d6686258 feat(tv): increase typography scale multipliers for better visibility 2026-01-25 23:02:52 +01:00
Fredrik Burmester
dca7cc99f2 feat(tv): add setting to show series poster on episode detail pages 2026-01-25 23:01:08 +01:00
Fredrik Burmester
875a017e8c feat(tv): add scalable typography with user-configurable text size 2026-01-25 22:55:44 +01:00
Fredrik Burmester
0c6c20f563 feat(tv): add horizontal gradient fade to hero carousel backdrop 2026-01-25 22:53:24 +01:00
Fredrik Burmester
2c9906377d feat(tv): update skeleton layout to match swapped poster position 2026-01-25 21:31:37 +01:00
Fredrik Burmester
d5f7a18fe5 chore: docs 2026-01-25 20:19:45 +01:00
Fredrik Burmester
4606b9718e feat(tv): swap layout and add horizontal posters for episodes 2026-01-25 20:18:12 +01:00
Fredrik Burmester
c2d61654b0 feat(tv): add glass poster module and refactor grid layouts 2026-01-25 17:02:10 +01:00
Fredrik Burmester
2c6938c739 fix: design 2026-01-25 14:50:05 +01:00
Fredrik Burmester
1f454c0f12 feat(tv): add Apple TV+ style hero carousel to home page 2026-01-24 23:43:40 +01:00
Fredrik Burmester
c215fda973 feat(tv): add 60s background refresh for idle screens 2026-01-24 10:48:42 +01:00
Fredrik Burmester
a852e2e769 fix(tv): reposition streamystats sections after recently added 2026-01-24 10:35:15 +01:00
Fredrik Burmester
29873e08d7 feat(tv): add shared filter components and collections page support 2026-01-24 10:31:03 +01:00
Fredrik Burmester
5ce5cc2d99 refactor(home): reposition streamystats sections after recently added 2026-01-24 10:29:04 +01:00
Fredrik Burmester
ae5a71ff29 fix(auth): clear react query cache on logout and enable home screen refetch 2026-01-24 10:25:33 +01:00
Fredrik Burmester
0e3e8b8016 Revert "fix: scaling"
This reverts commit 566ff485fb.
2026-01-24 10:05:22 +01:00
Fredrik Burmester
d07a521f60 feat(tv): add trickplay bubble positioning aligned with progress bar 2026-01-23 22:39:44 +01:00
Lance Chant
566ff485fb fix: scaling
Hopefully fixing scaling across different TV types for android/ios
Test for login screen at the moment

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-23 13:32:29 +02:00
Fredrik Burmester
3a4042efd5 fix(tv): android tv black screen 2026-01-23 08:21:00 +01:00
Fredrik Burmester
fb9b4b6f2d fix(tv): add padding to button 2026-01-22 09:09:30 +01:00
Fredrik Burmester
1b80db678e fix(tv): resolve mpv player exit freeze by async mpv cleanup 2026-01-22 09:08:51 +01:00
Fredrik Burmester
093fcc6187 fix(tv): season modal 2026-01-22 08:37:54 +01:00
Fredrik Burmester
26e8489384 fix(tv): season modal using correct modal 2026-01-22 08:37:35 +01:00
Fredrik Burmester
02a65059b9 fix(tv): set tv env 2026-01-22 08:37:14 +01:00
Fredrik Burmester
be2fd53f31 fix(tv): resolve subtitle selector index mismatch using VideoContext tracks 2026-01-22 08:29:57 +01:00
Fredrik Burmester
be92b5d75e feat(player): enhance technical info overlay with codec details 2026-01-22 08:15:02 +01:00
Fredrik Burmester
3f882ecade feat(tv): add technical info overlay to player controls 2026-01-22 08:10:18 +01:00
Fredrik Burmester
4b7007386f fix(tv): font size 2026-01-20 22:15:00 +01:00
Fredrik Burmester
d2790f4997 fix(tv): seek 2026-01-20 22:15:00 +01:00
Fredrik Burmester
096670a0c3 fix(tv): better seek 2026-01-20 22:15:00 +01:00
Fredrik Burmester
aa6b441dd1 feat(tv): minimal seekbar 2026-01-20 22:15:00 +01:00
Fredrik Burmester
d8512897ad feat: seekbar left/right actions 2026-01-20 22:15:00 +01:00
Fredrik Burmester
11b6f16cd3 fix: scale button 2026-01-20 22:15:00 +01:00
Fredrik Burmester
506d8b14dc fix(tv): wrap actor page in scrollview to fix focus navigation between sections 2026-01-20 22:15:00 +01:00
Fredrik Burmester
a8acdf4299 feat(tv): hide music and playlists libraries on tv 2026-01-20 22:15:00 +01:00
Fredrik Burmester
2a9f4c2885 fix: design 2026-01-20 22:15:00 +01:00
Fredrik Burmester
0353a718f3 feat(tv): seerr 2026-01-20 22:15:00 +01:00
Fredrik Burmester
e3b4952c60 fix(tv): resolve jellyseerr detail page focus navigation loop 2026-01-20 22:15:00 +01:00
Fredrik Burmester
5f44540b6f fix(tv): design 2026-01-19 20:01:00 +01:00
Fredrik Burmester
4705c9f4f9 feat(tv): add favorite button to item detail page 2026-01-19 20:01:00 +01:00
Fredrik Burmester
2b36d4bc76 fix(tv): font sizes 2026-01-19 20:01:00 +01:00
Fredrik Burmester
f4445c4152 chore(i18n): add movies and shows translation keys for tv actor page 2026-01-19 20:01:00 +01:00
Fredrik Burmester
16a236393d refactor(tv): migrate series season selector to navigation-based modal pattern 2026-01-19 20:01:00 +01:00
Fredrik Burmester
eeb4ef3008 feat(tv): split actor filmography into movies and series sections 2026-01-19 20:01:00 +01:00
Fredrik Burmester
a173db9180 wip 2026-01-19 08:21:55 +01:00
Fredrik Burmester
a8c07a31d3 fix(tv): remove extra left margin from see all card in collection lists 2026-01-18 22:20:43 +01:00
Fredrik Burmester
493df28b8d fix(player): resolve tvOS freeze on player exit by reordering mpv options 2026-01-18 22:11:35 +01:00
Fredrik Burmester
749473c1e8 feat(tv): add subtitle settings to subtitle modal
Add a new "Settings" tab to the TV subtitle modal with controls for:
- Subtitle Scale (0.5x to 2.0x)
- Vertical Margin (-100 to +100)
- Horizontal Alignment (left, center, right)
- Vertical Alignment (top, center, bottom)

All settings use mpvSubtitle* settings for direct MPV control.
Includes English translations for all new settings.
2026-01-18 20:41:28 +01:00
Fredrik Burmester
f8d1fad6d5 refactor 2026-01-18 20:06:09 +01:00
Fredrik Burmester
81af2afef8 feat(tv): add see all card to recently added sections with focus handling improvements 2026-01-18 19:58:10 +01:00
Fredrik Burmester
9ef79ef364 Merge branch 'develop' into feat/tv-interface 2026-01-18 19:38:27 +01:00
Fredrik Burmester
83babc2687 refactor 2026-01-18 19:33:42 +01:00
Fredrik Burmester
f9a3a1f9f6 feat(tv): add live subtitle track refresh after opensubs download 2026-01-18 17:44:13 +01:00
Fredrik Burmester
0f076d197f style(tv): update stepper buttons to square with rounded corners 2026-01-18 17:22:44 +01:00
Fredrik Burmester
d28b5411d5 style(tv): add apple tv-style badges to search page 2026-01-18 17:22:41 +01:00
Fredrik Burmester
1da49d29d7 style(tv): update settings to use apple tv-style white and green accents 2026-01-18 16:25:00 +01:00
Fredrik Burmester
7af4b913d7 fix(tv): add keyboard focus to text inputs and polish poster styling 2026-01-18 16:13:53 +01:00
Fredrik Burmester
a667723d93 fix(tv): improve subtitle modal loading state and card consistency 2026-01-18 15:25:40 +01:00
Fredrik Burmester
94bfa26041 feat(tv): add opensubtitles api key setting to tv interface 2026-01-18 15:25:31 +01:00
Fredrik Burmester
d545ca3584 fix(tv): modals 2026-01-18 15:22:44 +01:00
Fredrik Burmester
773701d0c1 fix: translations 2026-01-18 14:52:45 +01:00
Fredrik Burmester
a3f7d0c275 feat(tv): add metadata refresh button to item details page 2026-01-18 14:52:06 +01:00
Fredrik Burmester
5b7ded08cc refactor(tv): extract shared components to reduce code duplication 2026-01-18 14:45:18 +01:00
Fredrik Burmester
60dd00ad7e fix: close button modals 2026-01-18 14:14:23 +01:00
Fredrik Burmester
ec653cae15 docs: hdr 2026-01-18 13:53:19 +01:00
Fredrik Burmester
18bc45ea0a feat: open subtitles 2026-01-18 13:20:17 +01:00
Fredrik Burmester
ebb33854d7 wip 2026-01-18 12:37:12 +01:00
Fredrik Burmester
9efa2bbaa2 wip: hdr 2026-01-18 11:58:32 +01:00
Fredrik Burmester
c515d037cf refactor(tv): unify subtitle track selector and search into tabbed sheet 2026-01-18 11:13:57 +01:00
Fredrik Burmester
ee3a288fa0 wip 2026-01-18 10:38:06 +01:00
Fredrik Burmester
c0171aa656 feat(tv): add actor detail page with dynamic backdrop crossfade 2026-01-17 09:32:47 +01:00
Fredrik Burmester
41d3e61261 feat(tv): add bidirectional focus navigation between options and cast list 2026-01-17 09:10:27 +01:00
Fredrik Burmester
8f74c3edc7 feat(tv): actors and stuff 2026-01-16 23:36:15 +01:00
Fredrik Burmester
56ffec3173 fix(player): add null guards for item in play settings 2026-01-16 21:26:56 +01:00
Fredrik Burmester
9509a427c8 wip 2026-01-16 21:22:23 +01:00
Fredrik Burmester
cfcfb486bf wip 2026-01-16 21:21:58 +01:00
Fredrik Burmester
407ea69425 fix(tv): add opening animations to bottom sheet option selectors 2026-01-16 21:03:06 +01:00
Fredrik Burmester
e1e91ea1a6 fix: sheet 2026-01-16 21:00:46 +01:00
Fredrik Burmester
e7ea8a2c3b fix: remove back button 2026-01-16 19:51:27 +01:00
Fredrik Burmester
9f1791ce93 wip 2026-01-16 19:05:25 +01:00
Fredrik Burmester
38cb7068ef style(search): remove redundant search label on TV search page 2026-01-16 19:04:13 +01:00
Fredrik Burmester
cc154f0c16 fix(tv): fix subtitle sheet issues on TV
- Hide subtitle button when no subtitle tracks available
- Add back/menu button handling to close option sheets
2026-01-16 18:57:38 +01:00
Fredrik Burmester
866aa44277 wip: controls next up 2026-01-16 17:16:08 +01:00
Fredrik Burmester
ff3f88c53b wip 2026-01-16 15:59:26 +01:00
Fredrik Burmester
3fd76b1356 wip 2026-01-16 15:29:12 +01:00
Fredrik Burmester
a86df6c46b wip 2026-01-16 14:48:08 +01:00
Fredrik Burmester
bdd284b9a6 fix(i18n): add missing common.login translation key 2026-01-16 13:22:26 +01:00
Fredrik Burmester
fff7d4459f feat(tv): improve settings focus management with disabled props pattern 2026-01-16 13:17:12 +01:00
Fredrik Burmester
b85549016d style(tv): increase top padding on item content page 2026-01-16 13:15:53 +01:00
Fredrik Burmester
6c35608404 fix(tv): regenerate icons with proper aspect ratios 2026-01-16 13:09:30 +01:00
Fredrik Burmester
74e3465a84 feat(tv): add tv card design to watchlist detail page 2026-01-16 13:06:12 +01:00
Fredrik Burmester
be32d933bb feat(tv): add option selector for playback settings 2026-01-16 13:00:26 +01:00
Fredrik Burmester
db89295d9b feat(player): add Apple TV remote play/pause and AirPlay support
- Add playPause event handling in useRemoteControl hook
- Configure AVAudioSession for tvOS with longFormAudio policy
- Add AVInitialRouteSharingPolicy to enable AirPlay suggestions
2026-01-16 12:42:13 +01:00
Fredrik Burmester
8d90fe3a8b fix(tv): implement remote control seeking on tv interface 2026-01-16 12:40:37 +01:00
Fredrik Burmester
4880392197 fix(tv): login form 2026-01-16 12:19:47 +01:00
Fredrik Burmester
e10a99cc48 wip: build for tv 2026-01-16 10:47:48 +01:00
Fredrik Burmester
55b897883b wip 2026-01-16 10:06:41 +01:00
Fredrik Burmester
fe26a74451 wip: home page 2026-01-16 09:11:27 +01:00
Fredrik Burmester
4cdbab7d19 wip 2026-01-16 08:57:22 +01:00
Fredrik Burmester
3e695def23 wip 2026-01-16 08:57:19 +01:00
Fredrik Burmester
15e4c18d54 fix(tvos): settings 2026-01-16 08:42:53 +01:00
Fredrik Burmester
87169480a1 chore 2026-01-16 08:32:05 +01:00
Fredrik Burmester
bd9467b09e fix: remove music provider for tv 2026-01-16 08:32:02 +01:00
Fredrik Burmester
6216e7fdb7 fix: items content for tv 2026-01-16 08:31:53 +01:00
Fredrik Burmester
6d2e897c9f fix: badge for tv 2026-01-16 08:31:44 +01:00
Fredrik Burmester
ad5148daad fix: login stuff for tv 2026-01-16 08:31:37 +01:00
Fredrik Burmester
c1e12d5898 fix: login page for tv 2026-01-16 08:30:50 +01:00
Fredrik Burmester
7416c8297a fix: hide music bar 2026-01-16 08:30:40 +01:00
Fredrik Burmester
9727bec7ab fix: hide header buttons 2026-01-16 08:30:33 +01:00
Fredrik Burmester
6ba767a848 fix: tvos 2026-01-16 08:04:23 +01:00
Fredrik Burmester
4ad103acb6 fix: conditionals for tv to build / run 2026-01-16 08:04:09 +01:00
488 changed files with 56069 additions and 7010 deletions

View File

@@ -0,0 +1,103 @@
---
name: tv-validator
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
tools: Read, Glob, Grep
model: haiku
color: blue
---
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
## Critical Rules to Check
### 1. No .tv.tsx File Suffix
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
```typescript
if (Platform.isTV) {
return <TVMyComponent />;
}
return <MyComponent />;
```
### 2. No FlashList on TV
FlashList has focus issues on TV. Use FlatList instead.
**Violation**: `<FlashList` in TV code paths
**Correct**:
```typescript
{Platform.isTV ? (
<FlatList removeClippedSubviews={false} ... />
) : (
<FlashList ... />
)}
```
### 3. Modal Pattern
Never use overlay/absolute-positioned modals on TV. They break back button handling.
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
**Correct**: Use navigation-based pattern:
- Create Jotai atom for state
- Hook that sets atom and calls `router.push()`
- Page in `app/(auth)/` that reads atom
- `Stack.Screen` with `presentation: "transparentModal"`
### 4. Typography
All TV text must use `TVTypography` component.
**Violation**: Raw `<Text>` in TV components
**Correct**: `<TVTypography variant="title">...</TVTypography>`
### 5. No Purple Accent Colors
TV uses white for focus states, not purple.
**Violation**: Purple/violet colors in TV focused states
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
### 6. Focus Handling
- Only ONE element should have `hasTVPreferredFocus={true}`
- Focusable items need `disabled={isModalOpen}` when overlays are visible
- Use `onFocus`/`onBlur` with scale animations
- Add padding for scale animations (focus scale clips without it)
### 7. List Configuration
TV lists need:
- `removeClippedSubviews={false}`
- `overflow: "visible"` on containers
- Sufficient padding for focus scale animations
### 8. Horizontal Padding
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
### 9. Focus Guide Navigation
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
Use `useState` for refs (not `useRef`) to trigger re-renders.
## Review Process
1. Read the file(s) to review
2. Check each rule above
3. Report violations with:
- Line number
- What's wrong
- How to fix it
4. If no violations, confirm the code follows TV patterns
## Output Format
```
## TV Validation Results
### ✓ Passes
- [List of rules that pass]
### ✗ Violations
- **[Rule Name]** (line X): [Description]
Fix: [How to correct it]
### Recommendations
- [Optional suggestions for improvement]
```

View File

@@ -12,26 +12,59 @@ Analyze the current conversation to extract useful facts that should be remember
## Instructions ## Instructions
1. Read the existing facts file at `.claude/learned-facts.md` 1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded
2. Review this conversation for learnings worth preserving 2. Review this conversation for learnings worth preserving
3. For each new fact: 3. For each new fact:
- Write it concisely (1-2 sentences max) - Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
- Include context for why it matters - Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
- Add today's date
4. Skip facts that duplicate existing entries 4. Skip facts that duplicate existing entries
5. Append new facts to `.claude/learned-facts.md` 5. If a new category is needed, add it to the index in `CLAUDE.md`
## Fact Format ## Fact File Template
Use this format for each fact: Create each file at `.claude/learned-facts/[kebab-case-name].md`:
```
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_ ```markdown
# [Title]
**Date**: YYYY-MM-DD
**Category**: navigation | tv | native-modules | state-management | ui
**Key files**: `relevant/paths.ts`
## Detail
[Full description of the fact, including context for why it matters]
``` ```
## Example Facts ## Index Entry Format
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_ Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
After updating the file, summarize what facts you added (or note if nothing new was learned this session). ```
- `kebab-case-name` | Brief one-line summary of the fact
```
Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform
## Example
File `.claude/learned-facts/state-management-pattern.md`:
```markdown
# State Management Pattern
**Date**: 2025-01-09
**Category**: state-management
**Key files**: `utils/atoms/`
## Detail
Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`.
```
Index entry in `CLAUDE.md`:
```
State/Data:
- `state-management-pattern` | Use Jotai atoms for global state, not React Context
```
After updating, summarize what facts you added (or note if nothing new was learned this session).

View File

@@ -1,8 +1,11 @@
# Learned Facts # Learned Facts (DEPRECATED)
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions. > **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`.
> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
> New facts should be added as individual files using the `/reflect` command.
> This file is kept for reference only and is no longer auto-imported.
This file is auto-imported into CLAUDE.md and loaded at the start of each session. This file previously contained facts about the codebase learned from past sessions.
## Facts ## Facts
@@ -24,4 +27,22 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ - **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ - **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -0,0 +1,9 @@
# Header Button Locations
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
## Detail
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.

View File

@@ -0,0 +1,9 @@
# Intro Modal Trigger Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
## Detail
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.

View File

@@ -0,0 +1,9 @@
# IntroSheet Rendering Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
## Detail
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.

View File

@@ -0,0 +1,9 @@
# macOS Header Buttons Fix
**Date**: 2026-01-10
**Category**: ui
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.

View File

@@ -0,0 +1,9 @@
# Mark as Played Flow
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
## Detail
The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`.

View File

@@ -0,0 +1,9 @@
# MPV avfoundation-composite-osd Ordering
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).

View File

@@ -0,0 +1,9 @@
# MPV tvOS Player Exit Freeze
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.

View File

@@ -0,0 +1,9 @@
# Native Bottom Tabs + useRouter Conflict
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/`, `app/_layout.tsx`
## Detail
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.

View File

@@ -0,0 +1,9 @@
# Native SwiftUI View Sizing
**Date**: 2026-01-25
**Category**: native-modules
**Key files**: `modules/`
## Detail
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.

View File

@@ -0,0 +1,9 @@
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
**Date**: 2026-01-26
**Category**: tv
**Key files**: `app/`, `components/`
## Detail
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.

View File

@@ -0,0 +1,9 @@
# Stack Screen Header Configuration
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling.

View File

@@ -0,0 +1,9 @@
# Streamystats Components Location
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
## Detail
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.

View File

@@ -0,0 +1,9 @@
# Tab Folder Naming
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `app/(auth)/(tabs)/`
## Detail
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.

View File

@@ -0,0 +1,9 @@
# Thread-Safe State for Stop Flags
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.

View File

@@ -0,0 +1,9 @@
# TV Grid Layout Pattern
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`
## Detail
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.

View File

@@ -0,0 +1,9 @@
# TV Horizontal Padding Standard
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
## Detail
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.

View File

@@ -0,0 +1,9 @@
# TV Modals Must Use Navigation Pattern
**Date**: 2026-01-24
**Category**: tv
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
## Detail
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.

View File

@@ -0,0 +1,9 @@
# useNetworkAwareQueryClient Limitations
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.

View File

@@ -0,0 +1,25 @@
# Custom EAS Build config for Android phone APK (downloadable artifact).
# Same bun-forcing flow as android-production.yml, but builds an APK
# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
# Referenced from eas.json: build.production-apk.android.config
build:
name: Android phone APK (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
- run:
name: Prebuild (Android, bun)
command: bunx expo prebuild --platform android --no-install
- eas/configure_android_version
- eas/inject_android_credentials
- eas/run_gradle:
inputs:
command: :app:assembleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,27 @@
# Custom EAS Build config for Android TV APK (downloadable artifact).
# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
# eas.json) so prebuild generates the TV variant. Builds an APK for
# sideloading onto Android TV devices.
# Referenced from eas.json: build.production-apk-tv.android.config
build:
name: Android TV APK (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
- run:
name: Prebuild (Android TV, bun)
command: bunx expo prebuild --platform android --no-install
- eas/configure_android_version
- eas/inject_android_credentials
- eas/run_gradle:
inputs:
command: :app:assembleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,38 @@
# Custom EAS Build config for Android (production AAB).
#
# Why this exists: EAS's managed build can't detect Bun's text lockfile
# (bun.lock) and falls back to yarn, which breaks our install. The managed
# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
# manager detected based on your project", so we replace them with explicit
# `bun` commands. Everything else uses EAS's built-in functions so we still
# get remote versioning, credentials, and artifact upload.
#
# Referenced from eas.json: build.production.android.config = android-production.yml
build:
name: Android production (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
# android/ is gitignored, so generate native code fresh. --no-install
# because deps are already installed above; bunx keeps it on bun.
- run:
name: Prebuild (Android, bun)
command: bunx expo prebuild --platform android --no-install
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
# in eas.json) into the freshly prebuilt android/ project.
- eas/configure_android_version
# Injects the remote Android keystore / signing config.
- eas/inject_android_credentials
# Build the Play Store app bundle (.aab).
- eas/run_gradle:
inputs:
command: :app:bundleRelease
- eas/find_and_upload_build_artifacts

View File

@@ -0,0 +1,44 @@
# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
#
# Shared by both the iPhone profile (production) and the tvOS profile
# (production_tv). The profile decides the rest:
# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
# credentialsSource: local (EAS can't manage tvOS creds remotely).
# - production uses remote-managed iOS credentials.
#
# Like the Android configs, this replaces eas/install_node_modules and
# eas/prebuild (both auto-detect the wrong package manager) with explicit
# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
build:
name: iOS/tvOS App Store (bun)
steps:
- eas/checkout
- run:
name: Install dependencies (bun, frozen)
command: bun install --frozen-lockfile
- eas/resolve_apple_team_id_from_credentials:
id: resolve_team
# android/ + ios/ are gitignored, so generate native code fresh.
# EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
# skips JS + pod install; we install pods explicitly below with bun deps.
- run:
name: Prebuild (iOS/tvOS, bun)
command: bunx expo prebuild --platform ios --no-install
- run:
name: Install CocoaPods
working_directory: ./ios
command: pod install
- eas/configure_ios_credentials
- eas/configure_ios_version
- eas/generate_gymfile_from_template:
inputs:
credentials: ${ eas.job.secrets.buildCredentials }
- eas/run_fastlane
- eas/find_and_upload_build_artifacts

View File

@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs, It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support. and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies ## Main Technologies
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash) - `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins - `plugins/` Expo/Metro plugins
## Coding Standards ## Code Quality Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules - Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely - Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries - Implement proper error boundaries
- Use React.memo() for performance optimization - Use React.memo() for performance optimization when needed
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration ## API Integration
@@ -85,6 +108,18 @@ Exemples:
- `fix(auth): handle expired JWT tokens` - `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK` - `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions ## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV) - Prioritize cross-platform compatibility (mobile + TV)

View File

@@ -1,91 +1,54 @@
<!-- <!--
Pull Request Template for Streamyfin Use a conventional commit title for the PR title,
==================================== for example `feat(auth): add MFA`
Use this template to help reviewers understand the purpose of your PR All sections below are required. Write N/A if a section is not applicable.
and to ensure all necessary checks are completed before merging. If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
--> -->
# 📦 Pull Request # 📦 Pull Request
## 🔖 Summary <!--
🤖 AI ASSISTED?
Uncomment the line below if AI was used to assist with this PR:
-->
<!--
[![AI Assisted](https://img.shields.io/badge/AI_Assisted-18181b?style=for-the-badge&logo=openai&logoColor=white)](#) -->
## 📝 Description
<!-- <!--
A concise description of the changes introduced by this PR. A short description of the changes and why you're making them.
Example: Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
“Add real-time currency conversion widget to dashboard.”
--> -->
## 🏷️ Ticket / Issue ## 🏷️ Ticket / Issue
<!-- <!--
Link to the related ticket, issue or user story. Link to the related ticket, issue or user story.
You can also indicate if this PR supersedes a previous one. Example: Fixes #123
Example:
- Closes #123
- Fixes STREAMYFIN-456
- Resolves #789
- Supersedes #120
- Related: #130
--> -->
## 🛠️ Whats Changed
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
Spec: https://www.conventionalcommits.org/ -->
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
- Scope (optional): e.g., auth, billing, mobile
- Short summary: what changed and why (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
### 🔐 Security & Privacy Impact
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
### ⚡ Performance Impact
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI) ### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. --> <!--
Include screenshots of relevant UI changes for both Android and iOS.
Before/After, responsive states (if relevant).
-->
## ✅ Checklist ## ✅ Checklist
<!-- <!--
Review and check off items as you complete them. Review and check off items as you complete them.
--> -->
- [ ] Ive read the [contribution guidelines](CONTRIBUTING.md) - [ ] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts) - [ ] Verified that changes behave as expected for all platforms
- [ ] Type checks pass (tsc/biome/etc.) - [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [ ] Docs updated (README/ADR/usage/API) - [ ] No secrets, hardcoded credentials, or private config files are included
- [ ] No secrets/credentials included; env vars documented - [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
- [ ] Release notes/CHANGELOG entry added (if applicable)
- [ ] Verified locally that changes behave as expected
## 🔍 Testing Instructions ## 🔍 Testing Instructions
<!-- <!--
Describe how reviewers can test your changes. Describe how reviewers can test your changes. This will help the PR get merged faster.
Example: Example:
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname` 1. Open the settings page and scroll to the bottom
2. Install deps: `npm|pnpm|yarn|bun install` 2. Verify that the clear data button is visible and pressable
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`) 3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
4. Run tests: `npm|pnpm|yarn|bun test` 4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
5. Verification steps:
- [ ] Expected UI/endpoint behavior
- [ ] Logs show no errors
- [ ] Edge cases covered (list)
--> -->
## ⚙️ Deployment Notes
<!--
Describe any deployment considerations such as config, environment vars, or native builds.
-->
## 📝 Additional Notes
<!--
Any other information or references related to this PR.
-->

19
.github/renovate.json vendored
View File

@@ -25,6 +25,25 @@
"osvVulnerabilityAlerts": true, "osvVulnerabilityAlerts": true,
"configMigration": true, "configMigration": true,
"separateMinorPatch": true, "separateMinorPatch": true,
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
}
],
"customDatasources": {
"xcode": {
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
"format": "json",
"transformTemplates": [
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
]
}
},
"lockFileMaintenance": { "lockFileMaintenance": {
"vulnerabilityAlerts": { "vulnerabilityAlerts": {
"enabled": true, "enabled": true,

View File

@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: 🔍 Get PR and Artifacts - name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
// Check if we're running from a fork (more precise detection) // Check if we're running from a fork (more precise detection)
@@ -188,6 +188,17 @@ jobs:
if (latestAppsRun) { if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); 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 { try {
// Get all jobs for this workflow run // Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -216,13 +227,6 @@ jobs:
return; // Exit early return; // Exit early
} }
// Map job names to our build targets
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job // Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) { for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j => const job = jobs.jobs.find(j =>
@@ -236,7 +240,9 @@ jobs:
conclusion: job.conclusion, conclusion: job.conclusion,
url: job.html_url, url: job.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: job.started_at || latestAppsRun.created_at created_at: job.started_at || latestAppsRun.created_at,
started_at: job.started_at,
completed_at: job.completed_at
}; };
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else { } else {
@@ -247,22 +253,30 @@ jobs:
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at created_at: latestAppsRun.created_at,
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
} }
} }
} catch (error) { } catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status // Fallback to workflow-level status for every build target.
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = { // Keys must match jobMappings / buildTargets statusKey values.
const fallbackStatus = {
name: latestAppsRun.name, name: latestAppsRun.name,
status: latestAppsRun.status, status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at created_at: latestAppsRun.created_at,
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
for (const platform of Object.keys(jobMappings)) {
buildStatuses[platform] = fallbackStatus;
}
} }
// Collect artifacts if any job has completed successfully // Collect artifacts if any job has completed successfully
@@ -353,10 +367,12 @@ jobs:
// Process each expected build target individually // Process each expected build target individually
const buildTargets = [ const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, { name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, { name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, { name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/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 Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
]; ];
for (const target of buildTargets) { for (const target of buildTargets) {
@@ -371,16 +387,31 @@ jobs:
let status = '⏳ Pending'; let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*'; let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled // tvOS builds are temporarily disabled until feat/tv-interface
if (target.name === 'iOS TV') { // is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
status = '💤 Disabled'; status = '💤 Disabled';
downloadLink = '*Disabled for now*'; downloadLink = '*Disabled until feat/tv-interface is merged*';
} else if (matchingStatus) { } else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) { if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete'; status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
// Format file size
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
const sizeInfo = `(${sizeInMB} MB)`;
// Calculate build duration
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`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') { } else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`; status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*'; downloadLink = '*Build failed*';
@@ -408,7 +439,7 @@ jobs:
} }
} }
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
} }
commentBody += `\n`; commentBody += `\n`;

View File

@@ -41,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -88,7 +88,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -124,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -171,7 +171,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -195,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -216,12 +216,13 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
xcode-version: "26.2" # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@main uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -236,7 +237,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
@@ -259,12 +260,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -280,9 +281,10 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
xcode-version: "26.2" # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
@@ -293,73 +295,136 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa path: build/*.ipa
retention-days: 7 retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV build-ios-tv:
# build-ios-tv: # Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) # Re-enable by removing the `false &&` prefix below.
# runs-on: macos-26 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'))
# name: 🍎 Build iOS IPA (TV) runs-on: macos-26
# permissions: name: 🍎 Build tvOS IPA
# contents: read permissions:
# contents: read
# steps:
# - name: 📥 Checkout code steps:
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: 📥 Checkout code
# with: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
# fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
# submodules: recursive fetch-depth: 0
# show-progress: false submodules: recursive
# show-progress: false
# - name: 🍞 Setup Bun
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - name: 🍞 Setup Bun
# with: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
# bun-version: latest with:
# bun-version: latest
# - name: 💾 Cache Bun dependencies
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - name: 💾 Cache Bun dependencies
# with: uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
# path: ~/.bun/install/cache with:
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} path: ~/.bun/install/cache
# restore-keys: | key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
# ${{ runner.os }}-bun-cache restore-keys: |
# ${{ runner.os }}-bun-cache
# - name: 📦 Install dependencies and reload submodules
# run: | - name: 📦 Install dependencies and reload submodules
# bun install --frozen-lockfile run: |
# bun run submodule-reload bun install --frozen-lockfile
# bun run submodule-reload
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv - name: 🛠️ Generate project files
# run: bun run prebuild:tv
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1 - name: 🔧 Setup Xcode
# with: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
# xcode-version: '26.0.1' with:
# # renovate: datasource=custom.xcode depName=xcode versioning=loose
# - name: 🏗️ Setup EAS xcode-version: "26.4"
# uses: expo/expo-github-action@main
# with: - name: 🏗️ Setup EAS
# eas-version: latest uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
# token: ${{ secrets.EXPO_TOKEN }} with:
# eas-cache: true eas-version: latest
# token: ${{ secrets.EXPO_TOKEN }}
# - name: 🚀 Build iOS app eas-cache: true
# env:
# EXPO_TV: 1 - name: 🚀 Build iOS app
# run: eas build -p ios --local --non-interactive env:
# EXPO_TV: 1
# - name: 📅 Set date tag run: eas build -p ios --local --non-interactive
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
# - name: 📅 Set date tag
# - name: 📤 Upload IPA artifact run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# with: - name: 📤 Upload IPA artifact
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# path: build-*.ipa with:
# retention-days: 7 name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
build-ios-tv-unsigned:
# Unsigned tvOS build is enabled (compiles without Apple credentials).
# The signed tvOS job above stays disabled until tvOS provisioning
# profiles are set up in EAS (app + TopShelf targets).
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
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
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- 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
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7

View File

@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1 uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -25,7 +25,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -76,7 +76,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
@@ -107,12 +107,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest

216
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
name: 🚀 Release (EAS build + submit)
# On merge to main (gated by the `production` GitHub Environment approval),
# build all targets on EAS in parallel via custom bun build configs:
# 1. iOS phone → App Store (auto-submit)
# 2. tvOS → App Store (auto-submit)
# 3. Android AAB → Google Play (auto-submit)
# 4. Android phone APK→ downloadable artifact
# 5. Android TV APK → downloadable artifact
# Note: EAS queues builds based on your plan's concurrency; parallel jobs
# here just submit them — EAS may still run them serially.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
workflow_dispatch:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
environment: production
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
build:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: 🍎 iOS
platform: ios
profile: production
submit: true
- name: 📺 tvOS
platform: ios
profile: production_tv
submit: true
- name: 🤖 Android AAB
platform: android
profile: production
submit: true
- name: 🤖 Android APK
platform: android
profile: production-apk
submit: false
artifact_name: streamyfin-android-phone-apk
- name: 📺 Android TV APK
platform: android
profile: production-apk-tv
submit: false
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
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- 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
if: matrix.profile == 'production_tv'
env:
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
run: |
mkdir -p certs profiles
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# Android Play submit needs the Google Play service account JSON.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android' && matrix.submit
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
# App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
else
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# ── Submit builds: cloud build + auto-submit to the store ──
- name: 🚀 Build & submit (${{ matrix.name }})
if: matrix.submit
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive \
--wait
# ── Artifact builds: cloud build, then download + upload the APK ──
- name: 🏗️ Build artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--non-interactive \
--wait \
--json > build-result.json
URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
echo "Downloading artifact: $URL"
curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
- name: 📤 Upload APK artifact (${{ matrix.name }})
if: ${{ !matrix.submit }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}.apk
retention-days: 14
# Draft a GitHub Release with the two APKs attached. The tag comes from the
# merged-in app version (app.json → expo.version), NOT the auto-incremented
# build number — so cutting a release is a deliberate version bump via PR.
github-release:
name: 📦 Draft GitHub Release
needs: build
if: ${{ !cancelled() }}
runs-on: ubuntu-24.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
with:
fetch-depth: 0
show-progress: false
- name: 📦 Download APK artifacts from this run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p apks
gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
ls -la apks
- name: 📝 Draft release (tag = app.json version, not auto-bumped)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -e "console.log(require('./app.json').expo.version)")
TAG="v$VERSION"
echo "Release tag from merged app version: $TAG"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG exists — updating APK assets"
gh release upload "$TAG" apks/*.apk --clobber
else
echo "Creating draft release $TAG"
gh release create "$TAG" \
--draft \
--generate-notes \
--title "$TAG" \
apks/*.apk
fi

View File

@@ -21,14 +21,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
- name: 🔍 Extract minor version from app.json - name: 🔍 Extract minor version from app.json
id: minor id: minor
uses: actions/github-script@main uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with: with:
result-encoding: string result-encoding: string
script: | script: |
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

18
.gitignore vendored
View File

@@ -18,6 +18,9 @@ web-build/
/androidmobile /androidmobile
/androidtv /androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds # Module-specific Builds
modules/mpv-player/android/build modules/mpv-player/android/build
modules/player/android modules/player/android
@@ -50,8 +53,6 @@ npm-debug.*
.idea/ .idea/
.ruby-lsp .ruby-lsp
.cursor/ .cursor/
.claude/
CLAUDE.md
# Environment and Configuration # Environment and Configuration
expo-env.d.ts expo-env.d.ts
@@ -63,6 +64,8 @@ expo-env.d.ts
pc-api-7079014811501811218-719-3b9f15aeccf8.json pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json credentials.json
streamyfin-4fec1-firebase-adminsdk.json streamyfin-4fec1-firebase-adminsdk.json
/profiles/
certs/
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
@@ -72,4 +75,13 @@ modules/background-downloader/android/build/*
/modules/mpv-player/android/build /modules/mpv-player/android/build
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
!.eas/build/
!.eas/build/**
.claude/
.agents/skills/**
skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

158
CLAUDE.md
View File

@@ -1,9 +1,39 @@
# CLAUDE.md # CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Learned Facts Index
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
Navigation:
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
UI/Headers:
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
State/Data:
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
Native Modules:
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
TV Platform:
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration. Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
@@ -65,6 +95,7 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
**State Management**: **State Management**:
- Global state uses Jotai atoms in `utils/atoms/` - Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings - `settingsAtom` in `utils/atoms/settings.ts` for app settings
- **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state - `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query` - Server state uses React Query with `@tanstack/react-query`
@@ -128,9 +159,132 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones - Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):` - Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
## Platform Considerations ## Platform Considerations
- TV version uses `:tv` suffix for scripts - TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"` - Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast) - Some features disabled on TV (e.g., notifications, Chromecast)
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
**Pattern for TV-specific pages and components**:
```typescript
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
import { Login } from "@/components/login/Login";
import { TVLogin } from "@/components/login/TVLogin";
const LoginPage: React.FC = () => {
if (Platform.isTV) {
return <TVLogin />;
}
return <Login />;
};
export default LoginPage;
```
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
- Use `Platform.isTV` to conditionally render the appropriate component
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
### TV Option Selectors and Focus Management
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
### TV Focus Flickering Between Zones (Lists with Headers)
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
**Solutions:**
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
```typescript
{Platform.isTV ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
```
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
```typescript
// ✅ Good - only first filter button has preferred focus
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
```
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
```typescript
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
```
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
### TV Focus Guide Navigation (Non-Adjacent Sections)
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
```typescript
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
```
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.

View File

@@ -126,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
TV platform integration notes:
- [TV Discovery](./docs/tv-discovery.md)
## 👋 Get in Touch with Us ## 👋 Get in Touch with Us
Need assistance or have any questions? Need assistance or have any questions?

View File

@@ -6,6 +6,14 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { 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 // Only override googleServicesFile if env var is set

View File

@@ -2,13 +2,11 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.52.0", "version": "0.54.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -23,7 +21,8 @@
}, },
"UISupportsTrueScreenSizeOnMac": true, "UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true, "UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true "LSSupportsOpeningDocumentsInPlace": true,
"AVInitialRouteSharingPolicy": "LongFormAudio"
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -37,8 +36,6 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes",
"versionCode": 92,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -55,23 +52,41 @@
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv", [
"@react-native-tvos/config-tv",
{
"appleTVImages": {
"icon": "./assets/images/icon-tvos.png",
"iconSmall": "./assets/images/icon-tvos-small.png",
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
"topShelf": "./assets/images/icon-tvos-topshelf.png",
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
},
"infoPlist": {
"UIAppSupportsHDR": true
}
}
],
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.js", "./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[ [
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": {
"deploymentTarget": "15.6", "deploymentTarget": "16.4",
"useFrameworks": "static" "useFrameworks": "static",
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64"], "buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21", "kotlinVersion": "2.1.20",
"minSdkVersion": 26, "minSdkVersion": 26,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
@@ -118,14 +133,18 @@
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.js"], ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit-GPL", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
} }
] ]
], ],

View File

@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: Platform.OS !== "ios", headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none", headerBlurEffect: "none",

View File

@@ -16,7 +16,7 @@ export interface MenuLink {
icon: string; icon: string;
} }
export default function menuLinks() { export default function CustomLinksPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]); const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);

View File

@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() { export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -15,6 +16,10 @@ export default function favorites() {
}, []); }, []);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (Platform.isTV) {
return <TVFavorites />;
}
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled

View File

@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
@@ -43,44 +44,29 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='downloads/index' name='downloads/index'
options={{ options={{
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{
title: t("home.sessions.title"), title: t("home.sessions.title"),
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings' name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -95,100 +81,77 @@ export default function IndexLayout() {
), ),
}} }}
/> />
<Stack.Screen
name='companion-login'
options={{
title: t("companion_login.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
}}
/>
<Stack.Screen <Stack.Screen
name='settings/playback-controls/page' name='settings/playback-controls/page'
options={{ options={{
title: t("home.settings.playback_controls.title"), title: t("home.settings.playback_controls.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/audio-subtitles/page' name='settings/audio-subtitles/page'
options={{ options={{
title: t("home.settings.audio_subtitles.title"), title: t("home.settings.audio_subtitles.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/appearance/page' name='settings/appearance/page'
options={{ options={{
title: t("home.settings.appearance.title"), title: t("home.settings.appearance.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/music/page' name='settings/music/page'
options={{ options={{
title: t("home.settings.music.title"), title: t("home.settings.music.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/appearance/hide-libraries/page' name='settings/appearance/hide-libraries/page'
options={{ options={{
title: t("home.settings.other.hide_libraries"), title: t("home.settings.other.hide_libraries"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/plugins/page' name='settings/plugins/page'
options={{ options={{
title: t("home.settings.plugins.plugins_title"), title: t("home.settings.plugins.plugins_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -207,126 +170,77 @@ export default function IndexLayout() {
name='settings/plugins/marlin-search/page' name='settings/plugins/marlin-search/page'
options={{ options={{
title: "Marlin Search", title: "Marlin Search",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/plugins/jellyseerr/page' name='settings/plugins/jellyseerr/page'
options={{ options={{
title: "Jellyseerr", title: "Jellyseerr",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/plugins/streamystats/page' name='settings/plugins/streamystats/page'
options={{ options={{
title: "Streamystats", title: "Streamystats",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/plugins/kefinTweaks/page' name='settings/plugins/kefinTweaks/page'
options={{ options={{
title: "KefinTweaks", title: "KefinTweaks",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/intro/page' name='settings/intro/page'
options={{ options={{
title: t("home.settings.intro.title"), title: t("home.settings.intro.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/logs/page' name='settings/logs/page'
options={{ options={{
title: t("home.settings.logs.logs_title"), title: t("home.settings.logs.logs_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/network/page' name='settings/network/page'
options={{ options={{
title: t("home.settings.network.title"), title: t("home.settings.network.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -336,12 +250,8 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable onPress={() => _router.back()} className='pl-0.5'> headerShown: !Platform.isTV,
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -0,0 +1,7 @@
import { Platform } from "react-native";
import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
export default function CompanionLoginPage() {
if (Platform.isTV) return null;
return <CompanionLoginScreen />;
}

View File

@@ -1,4 +1,9 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet"; import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler"; import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -20,7 +26,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
export default function page() { export default function DownloadsPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom); const [_queue, _setQueue] = useAtom(queueAtom);
@@ -101,7 +107,7 @@ export default function page() {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<Pressable <Pressable
onPress={bottomSheetModalRef.current?.present} onPress={() => bottomSheetModalRef.current?.present()}
className='px-2' className='px-2'
> >
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -116,7 +122,7 @@ export default function page() {
} }
}, [showMigration]); }, [showMigration]);
const _deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
toast.success( toast.success(
@@ -127,7 +133,7 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const _deleteShows = () => const deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
@@ -138,7 +144,7 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
}); });
const _deleteOtherMedia = () => const deleteOtherMedia = () =>
Promise.all( Promise.all(
otherMedia otherMedia
.filter((item) => item.item.Type) .filter((item) => item.item.Type)
@@ -162,6 +168,9 @@ export default function page() {
), ),
); );
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return ( return (
<OfflineModeProvider isOffline={true}> <OfflineModeProvider isOffline={true}>
<ScrollView <ScrollView
@@ -256,6 +265,42 @@ export default function page() {
)} )}
</View> </View>
</ScrollView> </ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</OfflineModeProvider> </OfflineModeProvider>
); );
} }

View File

@@ -1,15 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home"; import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
const Index = () => { const Index = () => {
const { settings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />; return <Home />;
}; };

View File

@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
export default function page() { export default function SessionsPage() {
const { sessions, isLoading } = useSessions({} as useSessionsProps); const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
}; };
const getProgressPercentage = () => { const getProgressPercentage = () => {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) { if (!session.NowPlayingItem?.RunTimeTicks) {
return 0; return 0;
} }

View File

@@ -14,7 +14,11 @@ import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
export default function settings() { // TV-specific settings component
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
// Mobile settings component
function SettingsMobile() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom); const [_user] = useAtom(userAtom);
@@ -55,6 +59,20 @@ export default function settings() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
{Platform.OS !== "ios" && (
<View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
onPress={() =>
router.push("/(auth)/(tabs)/(home)/companion-login")
}
title={t("pairing.pair_with_phone")}
textColor='blue'
/>
</ListGroup>
</View>
)}
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />
</View> </View>
@@ -104,8 +122,17 @@ export default function settings() {
</ListGroup> </ListGroup>
</View> </View>
{!Platform.isTV && <StorageSettings />} <StorageSettings />
</View> </View>
</ScrollView> </ScrollView>
); );
} }
export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) {
return <SettingsTV />;
}
return <SettingsMobile />;
}

View File

@@ -0,0 +1,949 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
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 { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import type { TVOptionItem } from "@/components/tv";
import {
TVLogoutButton,
TVSectionHeader,
TVSettingsOptionButton,
TVSettingsRow,
TVSettingsStepper,
TVSettingsTextInput,
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { clearCache as clearAudioCache } from "@/providers/AudioStorage";
import {
apiAtom,
cacheVersionAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const [, setCacheVersion] = useAtom(cacheVersionAtom);
const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings.openSubtitlesApiKey || "",
);
// PIN/Password modal state for user switching
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
// Get current server and other accounts
const currentServer = useMemo(() => {
if (!api?.basePath) return null;
const servers = getPreviousServers();
return servers.find((s) => s.address === api.basePath) || null;
}, [api?.basePath]);
const otherAccounts = useMemo(() => {
if (!currentServer || !user?.Id) return [];
return currentServer.accounts.filter(
(account) => account.userId !== user.Id,
);
}, [currentServer, user?.Id]);
const hasOtherAccounts = otherAccounts.length > 0;
// Handle account selection from modal
const handleAccountSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
if (account.securityType === "none") {
// Direct login with saved credential
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
} else if (account.securityType === "pin") {
// Show PIN modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPinModalVisible(true);
} else if (account.securityType === "password") {
// Show password modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPasswordModalVisible(true);
}
};
// Handle successful PIN entry
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
try {
await loginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
}
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle password submission
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await loginWithPassword(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle switch user button press
const handleSwitchUser = () => {
if (!currentServer || !user?.Id) return;
showUserSwitchModal(currentServer, user.Id, {
onAccountSelect: handleAccountSelect,
});
};
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[
{
text: t("common.cancel", "Cancel"),
style: "cancel",
},
{
text: t("common.ok", "OK"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
storage.remove("REACT_QUERY_OFFLINE_CACHE");
await queryClient.resetQueries();
// 2. Clear expo-image cache (memory & disk)
await Image.clearDiskCache();
Image.clearMemoryCache();
// 3. Clear AudioStorage (music) cache
await clearAudioCache();
// 4. Clear TopShelf cache
clearTopShelfCacheSafely();
// 5. Clear Subtitle Cache
storage.remove("downloadedSubtitles.json");
const subtitlesDir = new Directory(
Paths.cache,
"streamyfin-subtitles",
);
if (subtitlesDir.exists) {
await subtitlesDir.delete();
}
// 6. Clear MMKV caches like extracted image colors and other non-essential storage keys
const keysToKeep = [
"settings",
"serverUrl",
"token",
"user",
"deviceId",
"previousServers",
"hasAskedForNotificationPermission",
"hasShownIntro",
"multiAccountMigrated",
"selectedTVServer",
"downloads.v2.json",
];
const allKeys = storage.getAllKeys();
for (const key of allKeys) {
if (!keysToKeep.includes(key)) {
storage.remove(key);
}
}
// 7. Increment cache version to force remount of components
setCacheVersion((v) => v + 1);
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
);
}
},
},
],
);
};
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [
{
label: t("home.settings.audio.transcode_mode.auto"),
value: AudioTranscodeMode.Auto,
selected: currentAudioTranscode === AudioTranscodeMode.Auto,
},
{
label: t("home.settings.audio.transcode_mode.stereo"),
value: AudioTranscodeMode.ForceStereo,
selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
},
{
label: t("home.settings.audio.transcode_mode.5_1"),
value: AudioTranscodeMode.Allow51,
selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
},
{
label: t("home.settings.audio.transcode_mode.passthrough"),
value: AudioTranscodeMode.AllowAll,
selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
},
],
[t, currentAudioTranscode],
);
// Subtitle mode options
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
() => [
{
label: t("home.settings.subtitles.modes.Default"),
value: SubtitlePlaybackMode.Default,
selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
},
{
label: t("home.settings.subtitles.modes.Smart"),
value: SubtitlePlaybackMode.Smart,
selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
},
{
label: t("home.settings.subtitles.modes.OnlyForced"),
value: SubtitlePlaybackMode.OnlyForced,
selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
},
{
label: t("home.settings.subtitles.modes.Always"),
value: SubtitlePlaybackMode.Always,
selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
},
{
label: t("home.settings.subtitles.modes.None"),
value: SubtitlePlaybackMode.None,
selected: currentSubtitleMode === SubtitlePlaybackMode.None,
},
],
[t, currentSubtitleMode],
);
// MPV alignment options
const alignXOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Left", value: "left", selected: currentAlignX === "left" },
{
label: "Center",
value: "center",
selected: currentAlignX === "center",
},
{ label: "Right", value: "right", selected: currentAlignX === "right" },
],
[currentAlignX],
);
const alignYOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Top", value: "top", selected: currentAlignY === "top" },
{
label: "Center",
value: "center",
selected: currentAlignY === "center",
},
{
label: "Bottom",
value: "bottom",
selected: currentAlignY === "bottom",
},
],
[currentAlignY],
);
// Cache mode options
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
() => [
{
label: t("home.settings.buffer.cache_auto"),
value: "auto",
selected: currentCacheMode === "auto",
},
{
label: t("home.settings.buffer.cache_yes"),
value: "yes",
selected: currentCacheMode === "yes",
},
{
label: t("home.settings.buffer.cache_no"),
value: "no",
selected: currentCacheMode === "no",
},
],
[t, currentCacheMode],
);
// VO driver options
const voDriverOptions: TVOptionItem<MpvVoDriver>[] = useMemo(
() => [
{
label: t("home.settings.vo_driver.gpu_next"),
value: "gpu-next",
selected: currentVoDriver === "gpu-next",
},
{
label: t("home.settings.vo_driver.gpu"),
value: "gpu",
selected: currentVoDriver === "gpu",
},
],
[t, currentVoDriver],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
{
label: t("home.settings.appearance.display_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.display_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.display_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.display_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
],
[t, currentTypographyScale],
);
// Language options
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
() => [
{
label: t("home.settings.languages.system"),
value: undefined,
selected: !currentLanguage,
},
...APP_LANGUAGES.map((lang) => ({
label: lang.label,
value: lang.value,
selected: currentLanguage === lang.value,
})),
],
[t, currentLanguage],
);
// Inactivity timeout options (TV security feature)
const currentInactivityTimeout =
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
() => [
{
label: t("home.settings.security.inactivity_timeout.disabled"),
value: InactivityTimeout.Disabled,
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
},
{
label: t("home.settings.security.inactivity_timeout.1_minute"),
value: InactivityTimeout.OneMinute,
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
},
{
label: t("home.settings.security.inactivity_timeout.5_minutes"),
value: InactivityTimeout.FiveMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.15_minutes"),
value: InactivityTimeout.FifteenMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.30_minutes"),
value: InactivityTimeout.ThirtyMinutes,
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.1_hour"),
value: InactivityTimeout.OneHour,
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
},
{
label: t("home.settings.security.inactivity_timeout.4_hours"),
value: InactivityTimeout.FourHours,
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
},
{
label: t("home.settings.security.inactivity_timeout.24_hours"),
value: InactivityTimeout.TwentyFourHours,
selected:
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
},
],
[t, currentInactivityTimeout],
);
// Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.audio.transcode_mode.auto");
}, [audioTranscodeModeOptions, t]);
const subtitleModeLabel = useMemo(() => {
const option = subtitleModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.subtitles.modes.Default");
}, [subtitleModeOptions, t]);
const alignXLabel = useMemo(() => {
const option = alignXOptions.find((o) => o.selected);
return option?.label || "Center";
}, [alignXOptions]);
const alignYLabel = useMemo(() => {
const option = alignYOptions.find((o) => o.selected);
return option?.label || "Bottom";
}, [alignYOptions]);
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.display_size_default");
}, [typographyScaleOptions, t]);
const cacheModeLabel = useMemo(() => {
const option = cacheModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.buffer.cache_auto");
}, [cacheModeOptions, t]);
const voDriverLabel = useMemo(() => {
const option = voDriverOptions.find((o) => o.selected);
return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
return option?.label || t("home.settings.languages.system");
}, [currentLanguage, t]);
const inactivityTimeoutLabel = useMemo(() => {
const option = inactivityTimeoutOptions.find((o) => o.selected);
return (
option?.label || t("home.settings.security.inactivity_timeout.disabled")
);
}, [inactivityTimeoutOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 120,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("home.settings.settings_title")}
</Text>
{/* Account Section */}
<TVSectionHeader title={t("home.settings.switch_user.account")} />
<TVSettingsOptionButton
label={t("home.settings.switch_user.switch_user")}
value={user?.Name || "-"}
onPress={handleSwitchUser}
disabled={!hasOtherAccounts || isAnyModalOpen}
isFirst
/>
{/* Security Section */}
<TVSectionHeader title={t("home.settings.security.title")} />
<TVSettingsOptionButton
label={t("home.settings.security.inactivity_timeout.title")}
value={inactivityTimeoutLabel}
onPress={() =>
showOptions({
title: t("home.settings.security.inactivity_timeout.title"),
options: inactivityTimeoutOptions,
onSelect: (value) =>
updateSettings({ inactivityTimeout: value }),
})
}
/>
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
onPress={() =>
showOptions({
title: t("home.settings.audio.transcode_mode.title"),
options: audioTranscodeModeOptions,
onSelect: (value) =>
updateSettings({ audioTranscodeMode: value }),
})
}
/>
{/* Subtitles Section */}
<TVSectionHeader
title={t("home.settings.subtitles.subtitle_title")}
/>
<TVSettingsOptionButton
label={t("home.settings.subtitles.subtitle_mode")}
value={subtitleModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.subtitles.subtitle_mode"),
options: subtitleModeOptions,
onSelect: (value) => updateSettings({ subtitleMode: value }),
})
}
/>
<TVSettingsToggle
label={t("home.settings.subtitles.set_subtitle_track")}
value={settings.rememberSubtitleSelections}
onToggle={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.1,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
onIncrease={() => {
const newValue = Math.min(
3.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
<TVSettingsStepper
label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
const newValue = Math.max(
0,
(settings.mpvSubtitleMarginY ?? 0) - 5,
);
updateSettings({ mpvSubtitleMarginY: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
100,
(settings.mpvSubtitleMarginY ?? 0) + 5,
);
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
onPress={() =>
showOptions({
title: "Vertical Alignment",
options: alignYOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
}),
})
}
/>
{/* OpenSubtitles Section */}
<TVSectionHeader
title={
t("home.settings.subtitles.opensubtitles_title") ||
"OpenSubtitles"
}
/>
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_hint") ||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
}
value={openSubtitlesApiKey}
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => updateSettings({ openSubtitlesApiKey })}
secureTextEntry
/>
<Text
style={{
color: "#6B7280",
fontSize: typography.callout - 4,
marginTop: 8,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Buffer Settings Section */}
<TVSectionHeader title={t("home.settings.buffer.title")} />
<TVSettingsOptionButton
label={t("home.settings.buffer.cache_mode")}
value={cacheModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.buffer.cache_mode"),
options: cacheModeOptions,
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
})
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}
onDecrease={() => {
const newValue = Math.max(
5,
(settings.mpvCacheSeconds ?? 10) - 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
120,
(settings.mpvCacheSeconds ?? 10) + 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
formatValue={(v) => `${v}s`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150}
onDecrease={() => {
const newValue = Math.max(
50,
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
500,
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
onDecrease={() => {
const newValue = Math.max(
25,
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
200,
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton
label={t("home.settings.appearance.display_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.display_size"),
options: typographyScaleOptions,
onSelect: (value) =>
updateSettings({ tvTypographyScale: value }),
})
}
/>
<TVSettingsOptionButton
label={t("home.settings.languages.app_language")}
value={languageLabel}
onPress={() =>
showOptions({
title: t("home.settings.languages.app_language"),
options: languageOptions,
onSelect: (value) =>
updateSettings({ preferedLanguage: value }),
})
}
/>
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
)}
value={settings.mergeNextUpAndContinueWatching}
onToggle={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_hero_carousel")}
value={settings.showTVHeroCarousel}
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_series_poster_on_episode")}
value={settings.showSeriesPosterOnEpisode}
onToggle={(value) =>
updateSettings({ showSeriesPosterOnEpisode: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.theme_music")}
value={settings.tvThemeMusicEnabled}
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton
label={t("home.settings.storage.clear_all_cache")}
value=''
onPress={handleClearCache}
isFirst
/>
{/* User Section */}
<TVSectionHeader
title={t("home.settings.user_info.user_info_title")}
/>
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}
showChevron={false}
/>
<TVSettingsRow
label={t("home.settings.user_info.server")}
value={api?.basePath || "-"}
showChevron={false}
/>
{/* Logout Button */}
<View style={{ marginTop: 48, alignItems: "center" }}>
<TVLogoutButton onPress={logout} />
</View>
</ScrollView>
</View>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
}

View File

@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function AppearanceHideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);

View File

@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function HideLibrariesPage() {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);

View File

@@ -88,12 +88,7 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View <View className='flex-1'>
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
@@ -117,7 +112,10 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView className='pb-4 px-4'> <ScrollView
className='pb-4 px-4'
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => ( {filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}> <View className='bg-neutral-900 rounded-xl p-3' key={index}>

View File

@@ -3,6 +3,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls"; import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
import { MpvVoSettings } from "@/components/settings/MpvVoSettings";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings"; import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings"; import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -26,6 +28,8 @@ export default function PlaybackControlsPage() {
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls className='mb-4' /> <GestureControls className='mb-4' />
<PlaybackControlsSettings /> <PlaybackControlsSettings />
<MpvBufferSettings />
<MpvVoSettings />
</MediaProvider> </MediaProvider>
</View> </View>
{!Platform.isTV && <ChromecastSettings />} {!Platform.isTV && <ChromecastSettings />}

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings(); const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true} disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4' className='p-4'
> >
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </DisabledSetting>

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function KefinTweaksPage() {
const { pluginSettings } = useSettings(); const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true} disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='px-4' className='p-4'
> >
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </DisabledSetting>

View File

@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function MarlinSearchPage() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient"; import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function StreamystatsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -114,7 +114,7 @@ export default function page() {
}; };
const handleRefreshFromServer = useCallback(async () => { const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true); const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values // Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl); setUrl(newUrl);

View File

@@ -0,0 +1,238 @@
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "expo-router";
import { TFunction } from "i18next";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
/**
* Factory function to create skip options for a specific segment type
* Reduces code duplication across all 5 segment types
*/
const useSkipOptions = (
settingKey:
| "skipIntro"
| "skipOutro"
| "skipRecap"
| "skipCommercial"
| "skipPreview",
settings: ReturnType<typeof useSettings>["settings"] | null,
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
t: TFunction<"translation", undefined>,
) => {
return useMemo(
() => [
{
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
type: "radio" as const,
label: option.label,
value: option.value,
selected: option.value === settings?.[settingKey],
onPress: () => updateSettings({ [settingKey]: option.value }),
})),
},
],
[settings?.[settingKey], updateSettings, t, settingKey],
);
};
export default function SegmentSkipPage() {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: t("home.settings.other.segment_skip_settings"),
});
}, [navigation, t]);
const skipIntroOptions = useSkipOptions(
"skipIntro",
settings,
updateSettings,
t,
);
const skipOutroOptions = useSkipOptions(
"skipOutro",
settings,
updateSettings,
t,
);
const skipRecapOptions = useSkipOptions(
"skipRecap",
settings,
updateSettings,
t,
);
const skipCommercialOptions = useSkipOptions(
"skipCommercial",
settings,
updateSettings,
t,
);
const skipPreviewOptions = useSkipOptions(
"skipPreview",
settings,
updateSettings,
t,
);
if (!settings) return null;
return (
<DisabledSetting disabled={false} className='px-4'>
<ListGroup>
<ListItem
title={t("home.settings.other.skip_intro")}
subtitle={t("home.settings.other.skip_intro_description")}
disabled={pluginSettings?.skipIntro?.locked}
>
<PlatformDropdown
groups={skipIntroOptions}
disabled={pluginSettings?.skipIntro?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_intro")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_outro")}
subtitle={t("home.settings.other.skip_outro_description")}
disabled={pluginSettings?.skipOutro?.locked}
>
<PlatformDropdown
groups={skipOutroOptions}
disabled={pluginSettings?.skipOutro?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_outro")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_recap")}
subtitle={t("home.settings.other.skip_recap_description")}
disabled={pluginSettings?.skipRecap?.locked}
>
<PlatformDropdown
groups={skipRecapOptions}
disabled={pluginSettings?.skipRecap?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_recap")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_commercial")}
subtitle={t("home.settings.other.skip_commercial_description")}
disabled={pluginSettings?.skipCommercial?.locked}
>
<PlatformDropdown
groups={skipCommercialOptions}
disabled={pluginSettings?.skipCommercial?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.other.segment_skip_${settings.skipCommercial}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_commercial")}
/>
</ListItem>
<ListItem
title={t("home.settings.other.skip_preview")}
subtitle={t("home.settings.other.skip_preview_description")}
disabled={pluginSettings?.skipPreview?.locked}
>
<PlatformDropdown
groups={skipPreviewOptions}
disabled={pluginSettings?.skipPreview?.locked}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.other.segment_skip_${settings.skipPreview}`,
)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.other.skip_preview")}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
}
const SEGMENT_SKIP_OPTIONS = (
t: TFunction<"translation", undefined>,
): Array<{
label: string;
value: "none" | "ask" | "auto";
}> => [
{
label: t("home.settings.other.segment_skip_auto"),
value: "auto",
},
{
label: t("home.settings.other.segment_skip_ask"),
value: "ask",
},
{
label: t("home.settings.other.segment_skip_none"),
value: "none",
},
];

View File

@@ -15,14 +15,24 @@ import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native"; import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -36,19 +46,29 @@ import {
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string }; const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState( const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP, ScreenOrientation.Orientation.PORTRAIT_UP,
); );
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -56,7 +76,7 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({ const { data: collection, isLoading: isCollectionLoading } = useQuery({
queryKey: ["collection", collectionId], queryKey: ["collection", collectionId],
queryFn: async () => { queryFn: async () => {
if (!api) return null; if (!api) return null;
@@ -71,6 +91,46 @@ const page: React.FC = () => {
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
useEffect(() => { useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" }); navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]); setSortOrder([SortOrderOption.Ascending]);
@@ -87,6 +147,18 @@ const page: React.FC = () => {
setSortBy([sortByOption]); setSortBy([sortByOption]);
}, [navigation, collection]); }, [navigation, collection]);
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
}, [screenWidth, orientation]);
const fetchItems = useCallback( const fetchItems = useCallback(
async ({ async ({
pageParam, pageParam,
@@ -98,7 +170,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user?.Id, userId: user?.Id,
parentId: collectionId, parentId: collectionId,
limit: 18, limit: Platform.isTV ? 36 : 18,
startIndex: pageParam, startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple. // Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]], sortBy: [sortBy[0]],
@@ -114,7 +186,7 @@ const page: React.FC = () => {
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series"], includeItemTypes: ["Movie", "Series", "Season"],
}); });
return response.data || null; return response.data || null;
@@ -123,6 +195,7 @@ const page: React.FC = () => {
api, api,
user?.Id, user?.Id,
collection, collection,
collectionId,
selectedGenres, selectedGenres,
selectedYears, selectedYears,
selectedTags, selectedTags,
@@ -131,39 +204,40 @@ const page: React.FC = () => {
], ],
); );
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
queryKey: [ useInfiniteQuery({
"collection-items", queryKey: [
collection, "collection-items",
selectedGenres, collectionId,
selectedYears, selectedGenres,
selectedTags, selectedYears,
sortBy, selectedTags,
sortOrder, sortBy,
], sortOrder,
queryFn: fetchItems, ],
getNextPageParam: (lastPage, pages) => { queryFn: fetchItems,
if ( getNextPageParam: (lastPage, pages) => {
!lastPage?.Items || if (
!lastPage?.TotalRecordCount || !lastPage?.Items ||
lastPage?.TotalRecordCount === 0 !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;
}
return undefined; return undefined;
},
const totalItems = lastPage.TotalRecordCount; initialPageParam: 0,
const accumulatedItems = pages.reduce( enabled: !!api && !!user?.Id && !!collection,
(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(() => { const flatData = useMemo(() => {
return ( return (
@@ -195,7 +269,6 @@ const page: React.FC = () => {
}} }}
> >
<ItemPoster item={item} /> <ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
@@ -203,9 +276,34 @@ const page: React.FC = () => {
[orientation], [orientation],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(home)");
router.push(navTarget as any);
};
const _insets = useSafeAreaInsets(); return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
}}
>
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
</View>
);
},
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
@@ -372,48 +470,315 @@ const page: React.FC = () => {
], ],
); );
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
// TV filter bar state
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
if (isLoading || isCollectionLoading) {
return (
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
}
if (!collection) return null; if (!collection) return null;
return ( // Mobile return
<FlashList if (!Platform.isTV) {
ListEmptyComponent={ return (
<View className='flex flex-col items-center justify-center h-full'> <FlashList
<Text className='font-bold text-xl text-neutral-500'> ListEmptyComponent={
{t("search.no_results")} <View className='flex flex-col items-center justify-center h-full'>
</Text> <Text className='font-bold text-xl text-neutral-500'>
</View> {t("search.no_results")}
} </Text>
extraData={[ </View>
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
} }
}} extraData={[
onEndReachedThreshold={0.5} selectedGenres,
ListHeaderComponent={ListHeaderComponent} selectedYears,
contentContainerStyle={{ paddingBottom: 24 }} selectedTags,
ItemSeparatorComponent={() => ( sortBy,
<View sortOrder,
style={{ ]}
width: 10, contentInsetAdjustmentBehavior='automatic'
height: 10, 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,
}}
/>
)}
/>
);
}
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/> />
)} <TVFilterButton
/> label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
</View>
{/* Grid */}
<FlatList
key={`${orientation}-${nrOfCols}`}
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>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
); );
}; };

View File

@@ -3,9 +3,8 @@ import { useLocalSearchParams } from "expo-router";
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { Platform, View } from "react-native";
import Animated, { import Animated, {
runOnJS,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -15,6 +14,10 @@ import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { useItemQuery } from "@/hooks/useItemQuery";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const ItemContentSkeletonTV = Platform.isTV
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
: null;
const Page: React.FC = () => { const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,14 +27,20 @@ const Page: React.FC = () => {
// Exclude MediaSources/MediaStreams from initial fetch for faster loading // Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato) // (especially important for plugins like Gelato)
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [ const {
data: item,
isError,
isLoading,
} = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources, ItemFields.MediaSources,
ItemFields.MediaSourceCount, ItemFields.MediaSourceCount,
ItemFields.MediaStreams, ItemFields.MediaStreams,
]); ]);
// Lazily preload item with full media sources in background // Lazily preload item with full media sources in background — never cache
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []); const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
gcTime: 0,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -40,33 +49,14 @@ const Page: React.FC = () => {
}; };
}); });
const fadeOut = (callback: any) => { // Fast fade out when item loads (no setTimeout delay)
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => { useEffect(() => {
if (item) { if (item) {
fadeOut(() => {}); opacity.value = withTiming(0, { duration: 150 });
} else { } else {
fadeIn(() => {}); opacity.value = withTiming(1, { duration: 150 });
} }
}, [item]); }, [item, opacity]);
if (isError) if (isError)
return ( return (
@@ -78,31 +68,46 @@ const Page: React.FC = () => {
return ( return (
<OfflineModeProvider isOffline={isOffline}> <OfflineModeProvider isOffline={isOffline}>
<View className='flex flex-1 relative'> <View className='flex flex-1 relative'>
<Animated.View {/* Always render ItemContent - it handles loading state internally on TV */}
pointerEvents={"none"} <ItemContent
style={[animatedStyle]} item={item}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black' itemWithSources={itemWithSources}
> isLoading={isLoading}
<View />
style={{
height: item?.Type === "Episode" ? 300 : 450, {/* Skeleton overlay - fades out when content loads */}
}} {!item && (
className='bg-transparent rounded-lg mb-4 w-full' <Animated.View
/> pointerEvents={"none"}
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> style={[animatedStyle]}
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' /> className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' /> >
<View className='flex flex-row space-x-1 mb-8'> {Platform.isTV && ItemContentSkeletonTV ? (
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <ItemContentSkeletonTV />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> ) : (
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' /> <View style={{ paddingHorizontal: 16, width: "100%" }}>
</View> <View
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' /> style={{
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> height: 450,
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' /> }}
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> className='bg-transparent rounded-lg mb-4 w-full'
</Animated.View> />
{item && <ItemContent item={item} itemWithSources={itemWithSources} />} <View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View>
)}
</View> </View>
</OfflineModeProvider> </OfflineModeProvider>
); );

View File

@@ -13,7 +13,7 @@ import {
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function page() { export default function JellyseerrCompanyPage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function page() { export default function JellyseerrGenrePage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -6,6 +6,7 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -21,6 +22,7 @@ import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast"; import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts"; import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
@@ -52,7 +54,8 @@ import type {
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const Page: React.FC = () => { // Mobile page component
const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -74,7 +77,7 @@ const Page: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {
@@ -542,4 +545,12 @@ const Page: React.FC = () => {
); );
}; };
// Platform-conditional page component
const Page: React.FC = () => {
if (Platform.isTV) {
return <TVJellyseerrPage />;
}
return <MobilePage />;
};
export default Page; export default Page;

View File

@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function page() { export default function JellyseerrPersonPage() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,13 +1,14 @@
import { Slot, Stack, withLayoutContext } from "expo-router";
import { import {
createMaterialTopTabNavigator, createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap, MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions, MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs"; } from "expo-router/js-top-tabs";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "expo-router/react-navigation";
import { Stack, withLayoutContext } from "expo-router"; import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator(); const { Navigator } = createMaterialTopTabNavigator();
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
>(Navigator); >(Navigator);
const Layout = () => { const Layout = () => {
// On TV, skip the Material Top Tab Navigator and render children directly
// The TV version handles its own tab navigation internally
if (Platform.isTV) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Slot />
</>
);
}
return ( return (
<> <>
<Stack.Screen options={{ title: "Live TV" }} /> <Stack.Screen options={{ title: "Live TV" }} />

View File

@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function LiveTvChannelsPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets(); const _insets = useSafeAreaInsets();

View File

@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow); const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() { export default function LiveTvGuidePage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View File

@@ -2,12 +2,21 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function page() {
if (Platform.isTV) {
return <TVLiveTVPage />;
}
return <MobileLiveTVPrograms />;
}
function MobileLiveTVPrograms() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
export default function page() { export default function LiveTvRecordingsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className='flex items-center justify-center h-full -mt-12'> <View className='flex items-center justify-center h-full -mt-12'>

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll"; import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { TVActorPage } from "@/components/persons/TVActorPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
// Render TV-optimized page on TV platforms
if (Platform.isTV) {
return <TVActorPage personId={personId} />;
}
return <MobileActorPage personId={personId} />;
};
const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);

View File

@@ -14,6 +14,7 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader"; import { SeriesHeader } from "@/components/series/SeriesHeader";
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
@@ -61,6 +62,7 @@ const page: React.FC = () => {
}); });
}, },
staleTime: isOffline ? Infinity : 60 * 1000, staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id), enabled: isOffline || (!!api && !!user?.Id),
}); });
@@ -116,7 +118,8 @@ const page: React.FC = () => {
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
), ),
staleTime: isOffline ? Infinity : 60, staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id), enabled: isOffline || (!!api && !!user?.Id),
}); });
@@ -159,6 +162,19 @@ const page: React.FC = () => {
// For offline mode, we can show the page even without backdropUrl // For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null; if (!item || (!isOffline && !backdropUrl)) return null;
// TV version
if (Platform.isTV) {
return (
<OfflineModeProvider isOffline={isOffline}>
<TVSeriesPage
item={item}
allEpisodes={allEpisodes}
isLoading={isLoading}
/>
</OfflineModeProvider>
);
}
return ( return (
<OfflineModeProvider isOffline={isOffline}> <OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView <ParallaxScrollView

View File

@@ -11,20 +11,38 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, View } from "react-native"; import {
FlatList,
Platform,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -48,6 +66,13 @@ import {
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const TV_PLAYLIST_SQUARE_SIZE = 180;
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams() as { const searchParams = useLocalSearchParams() as {
@@ -58,6 +83,8 @@ const Page = () => {
}; };
const { libraryId } = searchParams; const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
@@ -78,7 +105,54 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
// Fallback refresh for newly added content when returning to the library
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
useEffect(() => { useEffect(() => {
// Check for URL params first (from "See All" navigation) // Check for URL params first (from "See All" navigation)
@@ -162,6 +236,10 @@ const Page = () => {
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2; if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3; if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5; if (screenWidth < 800) return 5;
@@ -213,6 +291,8 @@ const Page = () => {
itemType = "Video"; itemType = "Video";
} else if (library.CollectionType === "musicvideos") { } else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo"; itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -232,6 +312,9 @@ const Page = () => {
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
...(Platform.isTV && library.CollectionType === "playlists"
? { mediaTypes: ["Video"] }
: {}),
}); });
return response.data || null; return response.data || null;
@@ -322,7 +405,88 @@ const Page = () => {
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
), ),
[orientation], [orientation, nrOfCols],
);
const renderTVItem = useCallback(
(item: BaseItemDto) => {
const handlePress = () => {
if (item.Type === "Playlist") {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: item.Id! },
});
return;
}
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
// Special rendering for Playlist items (square thumbnails)
if (item.Type === "Playlist") {
const playlistImageUrl = getPrimaryImageUrl({
api,
item,
width: TV_PLAYLIST_SQUARE_SIZE * 2,
});
return (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return (
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, api, typography],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -509,6 +673,188 @@ const Page = () => {
], ],
); );
// 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]);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: filterBy.length === 0,
},
...generalFilters.map((option) => ({
label: option.value,
value: option.key,
selected: filterBy.includes(option.key),
})),
],
[filterBy, generalFilters, t],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
const handleShowFilterByFilter = useCallback(() => {
showOptions({
title: t("library.filters.filter_by"),
options: tvFilterByOptions,
onSelect: (value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
});
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading) if (isLoading || isLibraryLoading)
@@ -518,43 +864,176 @@ const Page = () => {
</View> </View>
); );
// 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();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return ( return (
<FlashList <ScrollView
key={orientation} style={{ flex: 1 }}
ListEmptyComponent={ contentContainerStyle={{
<View className='flex flex-col items-center justify-center h-full'> paddingTop: insets.top + 100,
<Text className='font-bold text-xl text-neutral-500'> paddingBottom: insets.bottom + 60,
{t("library.no_results")} paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
</Text> }}
</View> onScroll={({ nativeEvent }) => {
} // Load more when near bottom
contentInsetAdjustmentBehavior='automatic' const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
data={flatData} const isNearBottom =
renderItem={renderItem} layoutMeasurement.height + contentOffset.y >=
extraData={[orientation, nrOfCols]} contentSize.height - 500;
keyExtractor={keyExtractor} if (isNearBottom && hasNextPage && !isFetching) {
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage(); fetchNextPage();
} }
}} }}
onEndReachedThreshold={1} scrollEventThrottle={400}
ListHeaderComponent={ListHeaderComponent} >
contentContainerStyle={{ {/* Filter bar */}
paddingBottom: 24, <View
paddingLeft: insets.left, style={{
paddingRight: insets.right, flexDirection: "row",
}} flexWrap: "nowrap",
ItemSeparatorComponent={() => ( justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={handleShowFilterByFilter}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View <View
style={{ style={{
width: 10, flex: 1,
height: 10, justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}} }}
/> >
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)} )}
/>
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
); );
}; };

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen} open={dropdownOpen}
onOpenChange={setDropdownOpen} onOpenChange={setDropdownOpen}
trigger={ trigger={
<View className='pl-1.5'> <View>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}

View File

@@ -1,109 +1,11 @@
import { import { Platform } from "react-native";
getUserLibraryApi, import { Libraries } from "@/components/library/Libraries";
getUserViewsApi, import { TVLibraries } from "@/components/library/TVLibraries";
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function index() { export default function LibrariesPage() {
const [api] = useAtom(apiAtom); if (Platform.isTV) {
const [user] = useAtom(userAtom); return <TVLibraries />;
const queryClient = useQueryClient(); }
const { settings } = useSettings();
const { t } = useTranslation(); return <Libraries />;
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
staleTime: 60,
});
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
queryKey: ["library", item.Id],
queryFn: async () => {
if (!item.Id || !user?.Id || !api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
userId: user?.Id,
});
return response.data;
},
staleTime: 60 * 1000,
});
}
}, [data]);
const insets = useSafeAreaInsets();
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className='h-4' />
)
}
/>
);
} }

View File

@@ -1,13 +1,13 @@
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import { import {
createMaterialTopTabNavigator, createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap, MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions, MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs"; } from "expo-router/js-top-tabs";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "expo-router/react-navigation";
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator(); const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -1,8 +1,8 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,8 +1,8 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api"; import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useNavigation, useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";

View File

@@ -7,8 +7,9 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -22,9 +23,11 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { import {
JellyseerrSearchSort, JellyseerrSearchSort,
@@ -36,12 +39,20 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { createStreamystatsApi } from "@/utils/streamystats"; import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -55,10 +66,13 @@ const exampleSearches = [
"The Mandalorian", "The Mandalorian",
]; ];
export default function search() { export default function SearchPage() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -199,9 +213,7 @@ export default function search() {
return []; return [];
} }
const url = `${ const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
@@ -209,7 +221,7 @@ export default function search() {
const ids = response1.data.ids; const ids = response1.data.ids;
if (!ids || !ids.length) { if (!ids?.length) {
return []; return [];
} }
@@ -293,6 +305,9 @@ export default function search() {
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: false,
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
}, },
}); });
}, [navigation]); }, [navigation]);
@@ -440,6 +455,180 @@ export default function search() {
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12; return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]); }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
// TV item press handler
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
// Jellyseerr search for TV
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
useQuery({
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
queryFn: async () => {
const params = {
query: new URLSearchParams(debouncedSearch || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
"id",
),
);
},
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
// Process Jellyseerr results for TV
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTvResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTVNoResults = useMemo(() => {
return (
!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length
);
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
// Fetch discover settings for TV (when no search query in Discover mode)
const { data: discoverSliders } = useQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length === 0,
});
// TV Jellyseerr press handlers
const handleJellyseerrMoviePress = useCallback(
(item: MovieResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.title,
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.MOVIE,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrTvPress = useCallback(
(item: TvResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.name,
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.TV,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrPersonPress = useCallback(
(item: PersonResult) => {
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
},
[router],
);
// Render TV search page
if (Platform.isTV) {
return (
<TVSearchPage
search={search}
setSearch={setSearch}
debouncedSearch={debouncedSearch}
movies={movies}
series={series}
episodes={episodes}
collections={collections}
actors={actors}
artists={artists}
albums={albums}
songs={songs}
playlists={playlists}
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}
jellyseerrMovies={jellyseerrMovieResults}
jellyseerrTv={jellyseerrTvResults}
jellyseerrPersons={jellyseerrPersonResults}
jellyseerrLoading={jellyseerrTVLoading}
jellyseerrNoResults={jellyseerrTVNoResults}
onJellyseerrMoviePress={handleJellyseerrMoviePress}
onJellyseerrTvPress={handleJellyseerrTvPress}
onJellyseerrPersonPress={handleJellyseerrPersonPress}
discoverSliders={discoverSliders}
/>
);
}
return ( return (
<ScrollView <ScrollView
keyboardDismissMode='on-drag' keyboardDismissMode='on-drag'
@@ -450,26 +639,6 @@ export default function search() {
paddingBottom: 60, paddingBottom: 60,
}} }}
> >
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View <View
className='flex flex-col' className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
@@ -772,7 +941,7 @@ export default function search() {
</Text> </Text>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'> <View className='mt-2 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => ( {exampleSearches.map((e) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {

View File

@@ -0,0 +1,21 @@
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SettingsLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.settings"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,5 @@
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
export default function SettingsTabScreen() {
return <SettingsTV />;
}

View File

@@ -8,7 +8,9 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Platform,
RefreshControl, RefreshControl,
ScrollView,
TouchableOpacity, TouchableOpacity,
useWindowDimensions, useWindowDimensions,
View, View,
@@ -16,11 +18,18 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { import {
useDeleteWatchlist, useDeleteWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,
@@ -32,9 +41,15 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
export default function WatchlistDetailScreen() { export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
@@ -47,6 +62,8 @@ export default function WatchlistDetailScreen() {
: undefined; : undefined;
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
// TV uses flexWrap, so nrOfCols is just for mobile
if (Platform.isTV) return 1;
if (screenWidth < 300) return 2; if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3; if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5; if (screenWidth < 800) return 5;
@@ -153,6 +170,28 @@ export default function WatchlistDetailScreen() {
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t], [removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
); );
const renderTVItem = useCallback(
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
};
return (
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, posterSizes.poster],
);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <TouchableItemRouter
@@ -266,6 +305,120 @@ export default function WatchlistDetailScreen() {
); );
} }
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return ( return (
<FlashList <FlashList
key={orientation} key={orientation}

View File

@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
name='[watchlistId]' name='[watchlistId]'
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -51,7 +51,7 @@ export default function WatchlistsLayout() {
options={{ options={{
title: t("watchlists.create_title"), title: t("watchlists.create_title"),
presentation: "modal", presentation: "modal",
headerShown: true, headerShown: !Platform.isTV,
headerStyle: { backgroundColor: "#171717" }, headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white", headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" }, contentStyle: { backgroundColor: "#171717" },
@@ -62,7 +62,7 @@ export default function WatchlistsLayout() {
options={{ options={{
title: t("watchlists.edit_title"), title: t("watchlists.edit_title"),
presentation: "modal", presentation: "modal",
headerShown: true, headerShown: !Platform.isTV,
headerStyle: { backgroundColor: "#171717" }, headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white", headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" }, contentStyle: { backgroundColor: "#171717" },

View File

@@ -3,20 +3,29 @@ import {
type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions, type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "expo-router/react-navigation";
import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar"; import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine"; import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
// Music components are not available on tvOS (TrackPlayer not supported)
const MiniPlayerBar = Platform.isTV
? () => null
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
const MusicPlaybackEngine = Platform.isTV
? () => null
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
@@ -30,6 +39,9 @@ export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
@@ -117,7 +129,20 @@ export default function TabLayout() {
: (_e) => ({ sfSymbol: "list.dash.fill" }), : (_e) => ({ sfSymbol: "list.dash.fill" }),
}} }}
/> />
<NativeTabs.Screen
name='(settings)'
options={{
title: t("tabs.settings"),
tabBarItemHidden: !Platform.isTV,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
</NativeTabs> </NativeTabs>
<CastingMiniPlayer />
<CastAutoplayWatcher />
<MiniPlayerBar /> <MiniPlayerBar />
<MusicPlaybackEngine /> <MusicPlaybackEngine />
</View> </View>

View File

@@ -0,0 +1,768 @@
/**
* Unified Casting Player Modal
* Protocol-agnostic full-screen player for all supported casting protocols
*/
import { router, Stack } from "expo-router";
import { useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import GoogleCast, {
CastState,
MediaPlayerState,
useCastDevice,
useCastState,
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector";
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
import { Text } from "@/components/common/Text";
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
import { useCasting } from "@/hooks/useCasting";
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
import { useCastSelection } from "@/hooks/useCastSelection";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
import { useSettings } from "@/utils/atoms/settings";
import { detectCapabilities } from "@/utils/casting/capabilities";
import { loadCastMedia } from "@/utils/casting/castLoad";
import { getPosterUrl } from "@/utils/casting/helpers";
import { resolveSelection } from "@/utils/casting/selection";
import type { CastSelection } from "@/utils/casting/types";
import { chapterMarkers } from "@/utils/chapters";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
export default function CastingPlayerScreen() {
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
// Chromecast autoplay countdown — watcher hook drives this atom; we render
// the overlay here when set, and handle Play-now / Cancel from the user.
const castAutoplay = useAtomValue(castAutoplayAtom);
const setCastAutoplay = useSetAtom(castAutoplayAtom);
// Get raw Chromecast state directly - same as old implementation
const castState = useCastState();
const mediaStatus = useMediaStatus();
const castDevice = useCastDevice();
// Keep hook active for connection - used by remoteMediaClient from useCasting
useRemoteMediaClient();
// Fetch full item data from Jellyfin by ID and derive the effective item
const { fetchedItem, currentItem } = useCastPlayerItem({
api,
user,
mediaStatus,
});
// Derive state from raw Chromecast hooks
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
const currentDevice = castDevice?.friendlyName ?? null;
// Progress/slider/trickplay cluster: slider shared values, scrub state,
// live-progress interpolation, resume-position tracking, trickplay preview.
const {
sliderProgress,
sliderMin,
sliderMax,
isScrubbing,
trickplayTime,
setTrickplayTime,
progress,
resumePositionRef,
trickPlayUrl,
calculateTrickplayUrl,
trickplayInfo,
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
// Only use casting controls if we have a current item to avoid "No session" errors
const castingControls = useCasting(currentItem);
const {
togglePlayPause,
skipForward,
skipBackward,
setVolume,
volume,
remoteMediaClient,
} = currentItem
? castingControls
: {
togglePlayPause: async () => {},
skipForward: async () => {},
skipBackward: async () => {},
setVolume: () => {},
volume: 1,
remoteMediaClient: null,
};
// Modal states
const [showEpisodeList, setShowEpisodeList] = useState(false);
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [chapterListVisible, setChapterListVisible] = useState(false);
// Chapter markers (shown for both episodes and movies).
const chapters = currentItem?.Chapters;
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
// Reload the cast stream with a full selection; resolves true on success.
const reloadWithSelection = useCallback(
async (selection: CastSelection): Promise<boolean> => {
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
console.warn("[Casting Player] Cannot reload - missing required data");
return false;
}
const currentPosition = resumePositionRef.current;
const result = await loadCastMedia({
client: remoteMediaClient,
device: castDevice,
api,
item: currentItem,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: {
mediaSourceId: selection.mediaSourceId,
audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex,
maxBitrate: selection.maxBitrate,
startPositionMs: currentPosition * 1000,
},
});
if (!result.ok) {
console.error(
"[Casting Player] Failed to reload stream:",
result.error,
);
return false;
}
return true;
},
[
api,
user?.Id,
currentItem,
remoteMediaClient,
castDevice,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
],
);
const { currentSelection, applySelection } = useCastSelection({
currentItem,
mediaStatus,
reload: reloadWithSelection,
});
// Episode/season cluster: episode list, next episode, season data, loader
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
useCastEpisodes({
api,
user,
currentItem,
remoteMediaClient,
castDevice,
settings,
});
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
// cast customData) still describes the previous episode. Used to suppress
// episode-dependent secondary UI that would otherwise flash stale data.
const isEpisodeTransitioning =
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
// Expose this player to the app-wide remote-control surface while a cast
// session is connected. The individual useCasting methods are each
// useCallback-wrapped and stable, so depend on them directly rather than on
// the whole `castingControls` object literal (rebuilt every render).
const {
togglePlayPause: castTogglePlayPause,
pause: castPause,
play: castPlay,
stop: castStop,
seek: castSeek,
setVolume: castSetVolume,
} = castingControls;
// toggleMute reads the latest volume without making `volume` a useMemo dep.
const volumeRef = useRef(volume);
volumeRef.current = volume;
const castController = useMemo<PlaybackController>(
() => ({
playPause: () => {
castTogglePlayPause();
},
pause: () => {
castPause();
},
unpause: () => {
castPlay();
},
stop: () => {
castStop();
},
seek: (positionMs) => {
castSeek(positionMs);
},
next: () => {
if (nextEpisode) loadEpisode(nextEpisode);
},
previous: () => {
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
if (idx > 0) loadEpisode(episodes[idx - 1]);
},
setVolume: (level) => {
castSetVolume(level);
},
toggleMute: () => {
castSetVolume(volumeRef.current > 0 ? 0 : 1);
},
}),
[
castTogglePlayPause,
castPause,
castPlay,
castStop,
castSeek,
castSetVolume,
episodes,
nextEpisode,
loadEpisode,
currentItem?.Id,
],
);
useRegisterPlaybackController(
castController,
castState === CastState.CONNECTED,
);
// The MediaSource currently selected, for deriving its tracks.
// Derived from fetchedItem: the slim cast-customData item strips per-source
// MediaStreams, so only the full fetched item yields correct track lists.
const selectedSource = useMemo(
() =>
fetchedItem?.MediaSources?.find(
(s) => s.Id === currentSelection?.mediaSourceId,
) ??
fetchedItem?.MediaSources?.[0] ??
null,
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
);
// Real alternate versions (multi-version items).
const availableVersions = useMemo(
() =>
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
id: s.Id ?? `source-${i}`,
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
})),
[fetchedItem?.MediaSources, t],
);
// Quality tiers from the shared ladder, capped to BOTH the device's
// capability and the media's own bitrate — a tier above either ceiling
// would behave identically to "Max", so it is not offered.
const availableQualities = useMemo(() => {
const caps = detectCapabilities(castDevice, {
profileMode: settings.chromecastProfile,
maxBitrate: settings.chromecastMaxBitrate,
});
const mediaBitrate =
selectedSource?.Bitrate ??
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
Number.POSITIVE_INFINITY;
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
}, [
castDevice,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
selectedSource,
fetchedItem?.MediaStreams,
]);
const availableAudioTracks = useMemo(() => {
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
if (!streams) return [];
return streams
.filter((stream) => stream.Type === "Audio")
.map((stream) => ({
index: stream.Index ?? 0,
language: stream.Language || "Unknown",
displayTitle:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
codec: stream.Codec || "Unknown",
channels: stream.Channels,
bitrate: stream.BitRate,
}));
}, [selectedSource, fetchedItem?.MediaStreams]);
const availableSubtitleTracks = useMemo(() => {
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
if (!streams) return [];
return streams
.filter((stream) => stream.Type === "Subtitle")
.map((stream) => ({
index: stream.Index ?? 0,
language: stream.Language || "Unknown",
displayTitle:
stream.DisplayTitle ||
[
stream.Language || "Unknown",
stream.IsForced ? " (Forced)" : "",
stream.Title ? ` - ${stream.Title}` : "",
].join(""),
codec: stream.Codec || "Unknown",
isForced: stream.IsForced || false,
isExternal: stream.IsExternal || false,
}));
}, [selectedSource, fetchedItem?.MediaStreams]);
// Autoplay overlay's "Play now" — load the queued next episode immediately.
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
// same start-position derivation) so the cast load is identical regardless
// of whether it is triggered by the user or by the countdown timer.
const onAutoplayPlayNow = useCallback(async () => {
if (!castAutoplay) return;
const episode = castAutoplay.nextEpisode;
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
setCastAutoplay(null);
return;
}
try {
const startPositionMs =
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
const result = await loadCastMedia({
client: remoteMediaClient,
device: castDevice,
api,
item: episode,
userId: user.Id,
profileMode: settings.chromecastProfile,
maxBitrateSetting: settings.chromecastMaxBitrate,
options: { startPositionMs },
});
if (!result.ok) {
console.error(
"[Casting Player] Failed to load next episode (play now):",
result.error,
);
return;
}
// Reset the autoplay counter on explicit user action.
updateSettings({ autoPlayEpisodeCount: 0 });
} catch (error) {
console.error(
"[Casting Player] Failed to load next episode (play now):",
error,
);
} finally {
setCastAutoplay(null);
}
}, [
castAutoplay,
api,
user?.Id,
remoteMediaClient,
castDevice,
settings.chromecastProfile,
settings.chromecastMaxBitrate,
updateSettings,
setCastAutoplay,
]);
// Poster URL for the queued next episode (mirrors `posterUrl` for the
// currently-playing item — same helper, same dimensions).
const autoplayPosterUrl = useMemo(() => {
if (!castAutoplay || !api?.basePath) return null;
const ep = castAutoplay.nextEpisode;
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
// call the helper with `undefined`. AutoplayCountdown handles null.
if (!ep?.Id) return null;
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
}, [castAutoplay, api?.basePath]);
// NOTE: Auto-navigation to casting-player is handled by higher-level
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
// do NOT call router.replace("/casting-player") here because this component
// IS the casting-player screen — doing so would cause redundant navigation loops.
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
const { currentSegment, skipIntro, skipCredits, skipSegment } =
useChromecastSegments(currentItem, progress * 1000, false);
// Swipe down to dismiss gesture
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
router,
});
// Memoize expensive calculations (before early return)
const posterUrl = useMemo(() => {
if (!api?.basePath || !currentItem?.Id) return null;
// For episodes, use SEASON poster instead of episode poster
if (currentItem.Type === "Episode" && seasonData?.Id) {
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
const seasonImageTag = seasonData.ImageTags?.Primary;
return seasonImageTag
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
}
// Fallback to item poster for non-episodes or if season data not loaded
return getPosterUrl(
api.basePath,
currentItem.Id,
currentItem.ImageTags?.Primary,
260,
390,
);
}, [
api?.basePath,
currentItem?.Id,
currentItem?.Type,
seasonData?.Id,
seasonData?.ImageTags?.Primary,
currentItem?.ImageTags?.Primary,
]);
const protocolColor = "#a855f7"; // Streamyfin purple
// Redirect if not connected - check CastState like old implementation
useEffect(() => {
// Redirect immediately when disconnected or no devices
if (
castState === CastState.NOT_CONNECTED ||
castState === CastState.NO_DEVICES_AVAILABLE
) {
// Use setTimeout to avoid state update during render
const timer = setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 100);
return () => clearTimeout(timer);
}
}, [castState, router]);
// Also redirect if mediaStatus disappears (media ended or stopped)
useEffect(() => {
if (castState === CastState.CONNECTED && !mediaStatus) {
const timer = setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 500); // Small delay to allow for media transitions
return () => clearTimeout(timer);
}
}, [castState, mediaStatus, router]);
// Show loading while connecting
if (castState === CastState.CONNECTING) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000",
alignItems: "center",
justifyContent: "center",
}}
>
<ActivityIndicator size='large' color='#fff' />
<Text style={{ color: "#fff", marginTop: 16 }}>
{t("casting_player.connecting")}
</Text>
</View>
);
}
// Don't render if not connected or no media playing
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
return null;
}
return (
<>
<Stack.Screen
options={{
headerShown: false,
title: "",
presentation: "fullScreenModal",
animation: "slide_from_bottom",
}}
/>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
flex: 1,
backgroundColor: "#000",
},
animatedStyle,
]}
>
{/* Header - Fixed at top */}
<CastPlayerHeader
insetTop={insets.top}
protocolColor={protocolColor}
currentDevice={currentDevice}
t={t}
onDismiss={dismissModal}
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
onPressSettings={() => setShowSettings(true)}
/>
{/* Title Area — hidden during an episode change to avoid flashing
the previous episode's title/season-episode numbers. */}
{!isEpisodeTransitioning && (
<CastPlayerTitle
insetTop={insets.top}
currentItem={currentItem}
t={t}
/>
)}
{/* Scrollable content area */}
<ScrollView
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: insets.top + 160,
paddingBottom: insets.bottom + 500,
}}
showsVerticalScrollIndicator={false}
>
{/* Poster with buffering overlay — force the overlay during an
episode change so the loading state covers the stale poster. */}
<CastPlayerPoster
posterUrl={posterUrl}
isBuffering={isBuffering || isEpisodeTransitioning}
currentSegment={currentSegment}
skipIntro={skipIntro}
skipCredits={skipCredits}
skipSegment={skipSegment}
remoteMediaClient={remoteMediaClient}
mediaStatus={mediaStatus}
protocolColor={protocolColor}
t={t}
/>
</ScrollView>
{/* Fixed control row - positioned independently. Episode-specific
buttons are conditional inside; Stop is always available. */}
<CastPlayerEpisodeControls
insetBottom={insets.bottom}
currentItemId={currentItem.Id}
episodes={episodes}
nextEpisode={nextEpisode}
remoteMediaClient={remoteMediaClient}
onPressEpisodes={() => setShowEpisodeList(true)}
hasChapters={hasChapters}
onPressChapters={() => setChapterListVisible(true)}
loadEpisode={loadEpisode}
router={router}
/>
{/* Fixed bottom controls area */}
<View
style={{
position: "absolute",
bottom: insets.bottom + 10,
left: 20,
right: 20,
zIndex: 98,
}}
>
{/* Progress slider with trickplay preview + time display */}
<CastPlayerProgressBar
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
isScrubbing={isScrubbing}
trickplayTime={trickplayTime}
setTrickplayTime={setTrickplayTime}
trickPlayUrl={trickPlayUrl}
calculateTrickplayUrl={calculateTrickplayUrl}
trickplayInfo={trickplayInfo}
progress={progress}
duration={duration}
remoteMediaClient={remoteMediaClient}
protocolColor={protocolColor}
chapters={currentItem?.Chapters}
t={t}
/>
{/* Playback controls */}
<CastPlayerTransportControls
isPlaying={isPlaying}
togglePlayPause={togglePlayPause}
skipBackward={skipBackward}
skipForward={skipForward}
rewindSkipTime={settings?.rewindSkipTime}
forwardSkipTime={settings?.forwardSkipTime}
protocolColor={protocolColor}
/>
</View>
{/* Autoplay countdown overlay — bottom-centred above the episode
control row and main controls. 320 wide card; centred via
left/right:0 + alignItems:"center". */}
{castAutoplay && (
<View
style={{
position: "absolute",
bottom: insets.bottom + 280,
left: 0,
right: 0,
alignItems: "center",
zIndex: 99,
}}
pointerEvents='box-none'
>
<AutoplayCountdown
nextEpisode={castAutoplay.nextEpisode}
posterUrl={autoplayPosterUrl}
secondsRemaining={castAutoplay.secondsRemaining}
onPlayNow={onAutoplayPlayNow}
onCancel={() => setCastAutoplay(null)}
/>
</View>
)}
{/* Modals */}
<ChromecastDeviceSheet
visible={showDeviceSheet}
onClose={() => setShowDeviceSheet(false)}
device={
currentDevice && castDevice
? { friendlyName: currentDevice }
: null
}
onDisconnect={async () => {
try {
// End the casting session and disconnect completely
const sessionManager = GoogleCast.getSessionManager();
await sessionManager.endCurrentSession(true);
setShowDeviceSheet(false);
// Close player immediately after disconnecting
setTimeout(() => {
if (router.canGoBack()) {
router.back();
} else {
router.replace("/(auth)/(tabs)/(home)/");
}
}, 100);
} catch (error) {
console.error(
"[Casting Player] Error disconnecting from Chromecast:",
error,
);
}
}}
volume={volume}
onVolumeChange={async (vol) => {
try {
setVolume(vol);
} catch (error) {
console.error("[Casting Player] Failed to set volume:", error);
}
}}
/>
<ChromecastEpisodeList
visible={showEpisodeList}
onClose={() => setShowEpisodeList(false)}
currentItem={currentItem}
episodes={episodes}
api={api}
onSelectEpisode={async (episode) => {
setShowEpisodeList(false);
await loadEpisode(episode);
}}
/>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={progress * 1000}
onSeek={(ms) => {
remoteMediaClient?.seek({ position: ms / 1000 });
}}
onClose={() => setChapterListVisible(false)}
/>
<ChromecastSettingsMenu
visible={showSettings}
onClose={() => setShowSettings(false)}
versions={availableVersions}
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
onVersionChange={(id) => {
if (!fetchedItem) return;
applySelection({
...resolveSelection(fetchedItem, { mediaSourceId: id }),
maxBitrate: currentSelection?.maxBitrate,
});
}}
qualities={availableQualities}
selectedMaxBitrate={currentSelection?.maxBitrate}
onQualityChange={(value) => applySelection({ maxBitrate: value })}
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
onAudioChange={(index) =>
applySelection({ audioStreamIndex: index })
}
subtitleTracks={
isEpisodeTransitioning ? [] : availableSubtitleTracks
}
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
onSubtitleChange={(index) =>
applySelection({ subtitleStreamIndex: index })
}
playbackSpeed={currentPlaybackSpeed}
onPlaybackSpeedChange={(speed) => {
setCurrentPlaybackSpeed(speed);
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
}}
/>
</Animated.View>
</GestureDetector>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
type MediaStream,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo, PlaybackProgressInfo,
RepeatMode, RepeatMode,
@@ -9,6 +10,7 @@ import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { File } from "expo-file-system";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -20,6 +22,7 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext"; import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext"; import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import { import {
@@ -43,11 +46,12 @@ import {
} from "@/modules"; } from "@/modules";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -55,10 +59,14 @@ import {
getMpvSubtitleId, getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils"; } from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native"; import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function page() { export default function DirectPlayerPage() {
const videoRef = useRef<MpvPlayerViewRef>(null); const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -85,6 +93,12 @@ export default function page() {
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false); const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
// TV audio/subtitle selection state (tracks current selection for dynamic changes)
const [currentAudioIndex, setCurrentAudioIndex] = useState<
number | undefined
>(undefined);
const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState<number>(-1);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
@@ -97,6 +111,9 @@ export default function page() {
// when data updates, only when the provider initializes // when data updates, only when the provider initializes
const downloadedFiles = downloadUtils.getDownloadedItems(); const downloadedFiles = downloadUtils.getDownloadedItems();
// Inactivity timer controls (TV only)
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -127,7 +144,6 @@ export default function page() {
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline });
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below // This is computed after downloadedItem is available, see audioIndexResolved below
@@ -142,6 +158,13 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const initialSeekDoneRef = useRef(false);
const initialPlaybackTicksRef = useRef<number>(
playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0),
);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>( const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null, null,
); );
@@ -150,6 +173,10 @@ export default function page() {
isError: false, isError: false,
}); });
// Playback manager for progress reporting and adjacent items
const playbackManager = usePlaybackManager({ item, isOffline: offline });
const { nextItem, previousItem } = playbackManager;
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback // Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => { const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) { if (audioIndexFromUrl !== undefined) {
@@ -161,6 +188,17 @@ export default function page() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params.
// No undefined guard: when a new episode's URL omits audioIndex, reset to
// undefined (media default) rather than leaking the previous episode's track.
useEffect(() => {
setCurrentAudioIndex(audioIndex);
}, [audioIndex]);
useEffect(() => {
setCurrentSubtitleIndex(subtitleIndex);
}, [subtitleIndex]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -187,12 +225,25 @@ export default function page() {
); );
/** Gets the initial playback position from the URL. */ /** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => { // const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) { // if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10); // return Number.parseInt(playbackPositionFromUrl, 10);
// }
// return item?.UserData?.PlaybackPositionTicks ?? 0;
// }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => {
if (!tracksReady || !videoRef.current) return;
if (initialSeekDoneRef.current) return;
initialSeekDoneRef.current = true;
const ticks = initialPlaybackTicksRef.current;
if (ticks > 0) {
videoRef.current.seekTo(ticksToSeconds(ticks));
} }
return item?.UserData?.PlaybackPositionTicks ?? 0; }, [tracksReady]);
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
@@ -206,7 +257,12 @@ export default function page() {
setDownloadedItem(data); setDownloadedItem(data);
} }
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ // Guard against api being null (e.g., during logout)
if (!api) {
setItemStatus({ isLoading: false, isError: false });
return;
}
const res = await getUserLibraryApi(api).getItem({
itemId, itemId,
userId: user?.Id, userId: user?.Id,
}); });
@@ -240,6 +296,7 @@ export default function page() {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
sessionId: string; sessionId: string;
url: string; url: string;
requiredHttpHeaders?: Record<string, string>;
} }
const [stream, setStream] = useState<Stream | null>(null); const [stream, setStream] = useState<Stream | null>(null);
@@ -248,19 +305,22 @@ export default function page() {
isError: false, isError: false,
}); });
// Ref to store the stream fetch function for refreshing subtitle tracks
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async (): Promise<Stream | null> => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
try { try {
// Don't attempt to fetch stream data if item is not available // Don't attempt to fetch stream data if item is not available
if (!item?.Id) { if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch"); console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
return; return null;
} }
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem && downloadedItem.mediaSource) { if (offline && downloadedItem?.mediaSource) {
const url = downloadedItem.videoFilePath; const url = downloadedItem.videoFilePath;
if (item) { if (item) {
result = { result = {
@@ -274,12 +334,12 @@ export default function page() {
if (!api) { if (!api) {
console.warn("API not available for streaming"); console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return; return null;
} }
if (!user?.Id) { if (!user?.Id) {
console.warn("User not authenticated for streaming"); console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return; return null;
} }
// Calculate start ticks directly from item to avoid stale closure // Calculate start ticks directly from item to avoid stale closure
@@ -298,25 +358,30 @@ export default function page() {
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(), deviceProfile: generateDeviceProfile(),
}); });
if (!res) return; if (!res) return null;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
if (!sessionId || !mediaSource || !url) { if (!sessionId || !mediaSource || !url) {
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.failed_to_get_stream_url"), t("player.failed_to_get_stream_url"),
); );
return; return null;
} }
result = { mediaSource, sessionId, url }; result = { mediaSource, sessionId, url, requiredHttpHeaders };
} }
setStream(result); setStream(result);
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
return result;
} catch (error) { } catch (error) {
console.error("Failed to fetch stream:", error); console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return null;
} }
}; };
// Store the fetch function in ref for use by refresh handler
refetchStreamRef.current = fetchStreamData;
fetchStreamData(); fetchStreamData();
}, [ }, [
itemId, itemId,
@@ -341,26 +406,6 @@ export default function page() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
};
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return; if (!item?.Id || !stream?.sessionId || offline || !api) return;
@@ -390,7 +435,9 @@ export default function page() {
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.pause(); videoRef.current?.pause();
revalidateProgressCache(); revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]); // Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -406,8 +453,11 @@ export default function page() {
return { return {
ItemId: item.Id, ItemId: item.Id,
AudioStreamIndex: audioIndex ? audioIndex : undefined, // Report the live selection so server-side session/resume state reflects
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, // mid-playback track changes. Note: index 0 is valid (don't treat as
// falsy); -1 means "off" and is reported as-is.
AudioStreamIndex: currentAudioIndex,
SubtitleStreamIndex: currentSubtitleIndex,
MediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()), PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying, IsPaused: !isPlaying,
@@ -421,14 +471,43 @@ export default function page() {
}, [ }, [
stream, stream,
item?.Id, item?.Id,
audioIndex, currentAudioIndex,
subtitleIndex, currentSubtitleIndex,
mediaSourceId, mediaSourceId,
progress, progress,
isPlaying, isPlaying,
isMuted, isMuted,
]); ]);
// Declared after currentPlayStateInfo so the dependency array can reference
// it without hitting the temporal dead zone.
const togglePlay = useCallback(async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
}
}, [
lightHapticFeedback,
isPlaying,
currentPlayStateInfo,
playbackManager,
offline,
api,
]);
const lastUrlUpdateTime = useSharedValue(0); const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false); const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
@@ -489,8 +568,8 @@ export default function page() {
}, },
[ [
item?.Id, item?.Id,
audioIndex, currentAudioIndex,
subtitleIndex, currentSubtitleIndex,
mediaSourceId, mediaSourceId,
isPlaying, isPlaying,
stream, stream,
@@ -500,11 +579,6 @@ export default function page() {
], ],
); );
/** Gets the initial playback position in seconds. */
const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */ /** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => { const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined; if (!item || !api) return undefined;
@@ -582,6 +656,15 @@ export default function page() {
autoplay: true, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
enabled: settings.mpvCacheEnabled,
cacheSeconds: settings.mpvCacheSeconds,
maxBytes: settings.mpvDemuxerMaxBytes,
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
},
// Pass VO driver setting (Android only)
voDriver: settings.mpvVoDriver,
}; };
// Add external subtitles only for online playback // Add external subtitles only for online playback
@@ -589,17 +672,32 @@ export default function page() {
source.externalSubtitles = externalSubs; source.externalSubtitles = externalSubs;
} }
// Add auth headers only for online streaming (not for local file:// URLs) // Add headers for online streaming (not for local file:// URLs)
if (!offline && api?.accessToken) { if (!offline) {
source.headers = { const headers: Record<string, string> = {};
Authorization: `MediaBrowser Token="${api.accessToken}"`, const isRemoteStream =
}; mediaSource?.IsRemote && mediaSource?.Protocol === "Http";
// Add auth header only for Jellyfin API requests (not for external/remote streams)
if (api?.accessToken && !isRemoteStream) {
headers.Authorization = `MediaBrowser Token="${api.accessToken}"`;
}
// Add any required headers from the media source (e.g., for external/remote streams)
if (stream?.requiredHttpHeaders) {
Object.assign(headers, stream.requiredHttpHeaders);
}
if (Object.keys(headers).length > 0) {
source.headers = headers;
}
} }
return source; return source;
}, [ }, [
stream?.url, stream?.url,
stream?.mediaSource, stream?.mediaSource,
stream?.requiredHttpHeaders,
item?.UserData?.PlaybackPositionTicks, item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl, playbackPositionFromUrl,
api?.basePath, api?.basePath,
@@ -607,6 +705,11 @@ export default function page() {
subtitleIndex, subtitleIndex,
audioIndex, audioIndex,
offline, offline,
settings.mpvCacheEnabled,
settings.mpvCacheSeconds,
settings.mpvDemuxerMaxBytes,
settings.mpvDemuxerMaxBackBytes,
settings.mpvVoDriver,
]); ]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
@@ -697,23 +800,27 @@ export default function page() {
setIsPlaying(true); setIsPlaying(true);
setIsBuffering(false); setIsBuffering(false);
setHasPlaybackStarted(true); setHasPlaybackStarted(true);
// Pause inactivity timer during playback (TV only)
pauseInactivityTimer();
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, currentPlayStateInfo() as PlaybackProgressInfo,
); );
} }
if (!Platform.isTV) await activateKeepAwakeAsync(); await activateKeepAwakeAsync();
return; return;
} }
if (isPaused) { if (isPaused) {
setIsPlaying(false); setIsPlaying(false);
// Resume inactivity timer when paused (TV only)
resumeInactivityTimer();
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, currentPlayStateInfo() as PlaybackProgressInfo,
); );
} }
if (!Platform.isTV) await deactivateKeepAwake(); await deactivateKeepAwake();
return; return;
} }
@@ -721,15 +828,19 @@ export default function page() {
setIsBuffering(isLoading); setIsBuffering(isLoading);
} }
}, },
[playbackManager, item?.Id, progress], [
playbackManager,
item?.Id,
progress,
pauseInactivityTimer,
resumeInactivityTimer,
],
); );
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback( const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => { (e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent; const { isActive } = e.nativeEvent;
setIsPipMode(isActive); setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) { if (isActive) {
_setShowControls(false); _setShowControls(false);
} }
@@ -747,6 +858,9 @@ export default function page() {
// Memoize video ref functions to prevent unnecessary re-renders // Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => { const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.(); return videoRef.current?.startPictureInPicture?.();
}, []); }, []);
@@ -763,6 +877,55 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000); videoRef.current?.seekTo?.(position / 1000);
}, []); }, []);
// TV audio track change handler
const handleAudioIndexChange = useCallback(
async (index: number) => {
setCurrentAudioIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined) {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
);
// TV subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
},
[stream?.mediaSource],
);
// Technical info toggle handler // Technical info toggle handler
const handleToggleTechnicalInfo = useCallback(() => { const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev); setShowTechnicalInfo((prev) => !prev);
@@ -773,6 +936,47 @@ export default function page() {
return (await videoRef.current?.getTechnicalInfo?.()) ?? {}; return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
}, []); }, []);
// App-wide remote control: wrap the player's existing handlers so remote
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
const playbackController = useMemo<PlaybackController>(
() => ({
// togglePlay flips play/pause and reports progress to the server.
playPause: () => {
void togglePlay();
},
pause: () => {
pause();
},
unpause: () => {
play();
},
stop: () => {
stop();
},
// PlaybackController seeks in ms; the player's seek already expects ms.
seek: (positionMs: number) => {
seek(positionMs);
},
// The player screen has no episode-loading path of its own — episode
// navigation is handled inside <Controls> via router replacement — so
// next/previous are honest no-ops here.
next: () => {},
previous: () => {},
// Volume is device-level (react-native-volume-manager); the slider sends
// 0-1 while setVolumeCb expects 0-100.
setVolume: (level: number) => {
void setVolumeCb(level * 100);
},
toggleMute: () => {
void toggleMuteCb();
},
}),
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
);
// Active for the whole lifetime of the player screen; cleared on unmount.
useRegisterPlaybackController(playbackController, true);
// Determine play method based on stream URL and media source // Determine play method based on stream URL and media source
const playMethod = useMemo< const playMethod = useMemo<
"DirectPlay" | "DirectStream" | "Transcode" | undefined "DirectPlay" | "DirectStream" | "Transcode" | undefined
@@ -852,6 +1056,113 @@ export default function page() {
} }
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true);
}, []);
// TV: Refresh subtitle tracks after server-side subtitle download
// Re-fetches the media source to pick up newly downloaded subtitles
const handleRefreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
if (!refetchStreamRef.current) return [];
const newStream = await refetchStreamRef.current();
// Check if component is still mounted before updating state
// This callback may be invoked from a modal after the player unmounts
if (!isMounted) return [];
if (newStream) {
setStream(newStream);
return (
newStream.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
) ?? []
);
}
return [];
}, [isMounted]);
// TV: Navigate to next item
const goToNextItem = useCallback(() => {
if (!nextItem || !settings || isPlaybackStopped) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(nextItem, settings, {
indexes: {
// Use the live selection (updated when the user changes tracks
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
isPlaybackStopped,
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return; if (!isVideoLoaded || !videoRef.current) return;
@@ -871,14 +1182,27 @@ export default function page() {
if (settings.mpvSubtitleAlignY !== undefined) { if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
} }
if (settings.mpvSubtitleFontSize !== undefined) { // Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
await videoRef.current?.setSubtitleFontSize?.( // mpv uses #RRGGBBAA format (alpha last, same as CSS)
settings.mpvSubtitleFontSize, if (settings.mpvSubtitleBackgroundEnabled) {
const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
const alphaHex = Math.round((opacity / 100) * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
// Enable background-box mode (required for sub-back-color to work)
await videoRef.current?.setSubtitleBorderStyle?.("background-box");
await videoRef.current?.setSubtitleBackgroundColor?.(
`#000000${alphaHex}`,
); );
} // Force override ASS subtitle styles so background shows on styled subtitles
// Apply subtitle size from general settings await videoRef.current?.setSubtitleAssOverride?.("force");
if (settings.subtitleSize) { } else {
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize); // Restore default outline-and-shadow style
await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
// Restore default ASS behavior (keep original styles)
await videoRef.current?.setSubtitleAssOverride?.("no");
} }
}; };
@@ -899,6 +1223,28 @@ export default function page() {
applyInitialPlaybackSpeed(); applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed]); }, [isVideoLoaded, initialPlaybackSpeed]);
// TV only: Pre-load locally downloaded subtitles when video loads
// This adds them to MPV's track list without auto-selecting them
useEffect(() => {
if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
return;
const preloadLocalSubtitles = async () => {
const localSubs = getSubtitlesForItem(itemId);
for (const sub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(sub.filePath);
if (!subtitleFile.exists) {
continue;
}
// Add subtitle file to MPV without selecting it (select: false)
await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
}
};
preloadLocalSubtitles();
}, [isVideoLoaded, itemId]);
// Show error UI first, before checking loading/missingdata // Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) { if (itemStatus.isError || streamStatus.isError) {
return ( return (
@@ -961,12 +1307,13 @@ export default function page() {
nowPlayingMetadata={nowPlayingMetadata} nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), 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); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
@@ -991,37 +1338,72 @@ export default function page() {
</View> </View>
)} )}
</View> </View>
{isMounted === true && item && !isPipMode && ( {isMounted === true &&
<Controls item &&
mediaSource={stream?.mediaSource} !isPipMode &&
item={item} (Platform.isTV ? (
togglePlay={togglePlay} <TVControls
isPlaying={isPlaying} mediaSource={stream?.mediaSource}
isSeeking={isSeeking} item={item}
progress={progress} togglePlay={togglePlay}
cacheProgress={cacheProgress} isPlaying={isPlaying}
isBuffering={isBuffering} isSeeking={isSeeking}
showControls={showControls} progress={progress}
setShowControls={setShowControls} cacheProgress={cacheProgress}
startPictureInPicture={startPictureInPicture} isBuffering={isBuffering}
play={play} showControls={showControls}
pause={pause} setShowControls={setShowControls}
seek={seek} play={play}
enableTrickplay={true} pause={pause}
aspectRatio={aspectRatio} seek={seek}
isZoomedToFill={isZoomedToFill} audioIndex={currentAudioIndex}
onZoomToggle={handleZoomToggle} subtitleIndex={currentSubtitleIndex}
api={api} onAudioIndexChange={handleAudioIndexChange}
downloadedFiles={downloadedFiles} onSubtitleIndexChange={handleSubtitleIndexChange}
playbackSpeed={currentPlaybackSpeed} previousItem={previousItem}
setPlaybackSpeed={handleSetPlaybackSpeed} nextItem={nextItem}
showTechnicalInfo={showTechnicalInfo} goToPreviousItem={goToPreviousItem}
onToggleTechnicalInfo={handleToggleTechnicalInfo} goToNextItem={goToNextItem}
getTechnicalInfo={getTechnicalInfo} onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
playMethod={playMethod} addSubtitleFile={addSubtitleFile}
transcodeReasons={transcodeReasons} showTechnicalInfo={showTechnicalInfo}
/> onToggleTechnicalInfo={handleToggleTechnicalInfo}
)} getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
) : (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
))}
</View> </View>
</VideoProvider> </VideoProvider>
</PlayerProvider> </PlayerProvider>

View File

@@ -0,0 +1,189 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
if (!modalState?.options) return 0;
const idx = modalState.options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [modalState?.options]);
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvOptionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (value: any) => {
modalState?.onSelect(value);
store.set(tvOptionModalAtom, null);
router.back();
};
const handleClose = useCallback(() => {
store.set(tvOptionModalAtom, null);
router.back();
}, [router]);
// Intercept back/menu press to close the modal instead of the player
useTVBackPress(() => {
handleClose();
return true;
}, [handleClose]);
// If no modal state, just go back (shouldn't happen in normal usage)
if (!modalState) {
return null;
}
const { title, options } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{title}
</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)}
width={scaledCardWidth}
height={scaledCardHeight}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: scaleSize(24),
paddingBottom: scaleSize(50),
overflow: "visible",
},
title: {
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(16),
paddingHorizontal: scaleSize(48),
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(20),
gap: scaleSize(12),
},
});

View File

@@ -0,0 +1,496 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: modalState?.id ? Number(modalState.id) : 0,
mediaType: modalState?.mediaType,
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvRequestModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
modalState?.mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled:
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
});
const defaultProfile: QualityProfile | undefined = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder | undefined = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(() => {
return (
defaultServiceDetails?.tags.filter((t) =>
defaultServiceDetails?.server.activeTags?.includes(t.id),
) ?? []
);
}, [defaultServiceDetails]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
// Option builders
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
() =>
defaultServiceDetails?.profiles.map((profile) => ({
label: profile.name,
value: profile.id,
selected:
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
})) || [],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
() =>
defaultServiceDetails?.rootFolders.map((folder) => ({
label: pathTitleExtractor(folder),
value: folder.path,
selected:
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
})) || [],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const userOptions: TVOptionItem<number>[] = useMemo(
() =>
users?.map((user) => ({
label: user.displayName,
value: user.id,
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
})) || [],
[users, jellyseerrUser, requestOverrides.userId],
);
const tagItems = useMemo(() => {
return (
defaultServiceDetails?.tags.map((tag) => ({
id: tag.id,
label: tag.label,
selected:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
})) ?? []
);
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
// Selected display values
const selectedProfileName = useMemo(() => {
const profile = defaultServiceDetails?.profiles.find(
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
);
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
}, [
defaultServiceDetails?.profiles,
requestOverrides.profileId,
defaultProfile,
t,
]);
const selectedFolderName = useMemo(() => {
const folder = defaultServiceDetails?.rootFolders.find(
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
);
return folder
? pathTitleExtractor(folder)
: defaultFolder
? pathTitleExtractor(defaultFolder)
: t("jellyseerr.select");
}, [
defaultServiceDetails?.rootFolders,
requestOverrides.rootFolder,
defaultFolder,
t,
]);
const selectedUserName = useMemo(() => {
const user = users?.find(
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
);
return (
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
);
}, [users, requestOverrides.userId, jellyseerrUser, t]);
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
(tagId: number) => {
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tagId);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tagId)
: [...currentTags, tagId],
};
});
},
[defaultTags],
);
const handleRequest = useCallback(() => {
if (!modalState) return;
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...modalState.requestBody,
...requestOverrides,
};
const seasonTitle =
modalState.requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: modalState.requestBody.seasons[0],
})
: modalState.requestBody?.seasons &&
modalState.requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
body,
() => {
modalState.onRequested();
router.back();
},
);
}, [
modalState,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
requestMedia,
router,
t,
]);
if (!modalState) {
return null;
}
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.advanced")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{isDataLoaded && isReady ? (
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.optionsContainer}>
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
<TVToggleOptionRow
label={t("jellyseerr.tags")}
items={tagItems}
onToggle={handleTagToggle}
/>
)}
</View>
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t("common.loading")}</Text>
</View>
)}
{isReady && (
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={[
styles.buttonText,
{ fontSize: typography.callout },
]}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
maxHeight: 320,
overflow: "visible",
},
optionsContainer: {
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
},
loadingContainer: {
height: 200,
justifyContent: "center",
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.5)",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -0,0 +1,446 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { orderBy } from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
season: {
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
};
selected: boolean;
onToggle: () => void;
canRequest: boolean;
hasTVPreferredFocus?: boolean;
}
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
season,
selected,
onToggle,
canRequest,
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
// Get status icon and color based on MediaStatus
const getStatusIcon = (): {
icon: keyof typeof MaterialCommunityIcons.glyphMap;
color: string;
} | null => {
switch (season.status) {
case MediaStatus.PROCESSING:
return { icon: "clock", color: "#6366f1" };
case MediaStatus.AVAILABLE:
return { icon: "check", color: "#22c55e" };
case MediaStatus.PENDING:
return { icon: "bell", color: "#eab308" };
case MediaStatus.PARTIALLY_AVAILABLE:
return { icon: "minus", color: "#22c55e" };
case MediaStatus.BLACKLISTED:
return { icon: "eye-off", color: "#ef4444" };
default:
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
}
};
const statusInfo = getStatusIcon();
const isDisabled = !canRequest;
return (
<Pressable
onPress={canRequest ? onToggle : undefined}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
styles.seasonCard,
{
backgroundColor: focused
? "#FFFFFF"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderWidth: focused ? 0 : 1,
borderColor: selected
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.1)",
opacity: isDisabled ? 0.5 : 1,
},
]}
>
{/* Checkmark for selected */}
<View style={styles.checkmarkContainer}>
{selected && (
<Ionicons
name='checkmark-circle'
size={24}
color={focused ? "#22c55e" : "#FFFFFF"}
/>
)}
</View>
{/* Season info */}
<View style={styles.seasonInfo}>
<Text
style={[
styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" },
]}
numberOfLines={1}
>
{t("jellyseerr.season_number", {
season_number: season.seasonNumber,
})}
</Text>
<View style={styles.episodeRow}>
<Text
style={[
styles.episodeCount,
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
>
{t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
})}
</Text>
{statusInfo && (
<View
style={[
styles.statusBadge,
{ backgroundColor: statusInfo.color },
]}
>
<MaterialCommunityIcons
name={statusInfo.icon}
size={14}
color='#FFFFFF'
/>
</View>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};
export default function TVSeasonSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
const { requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
new Set(),
);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Initialize selected seasons when modal state changes
useEffect(() => {
if (modalState?.seasons) {
const requestableSeasons = modalState.seasons
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map((s) => s.seasonNumber);
setSelectedSeasons(new Set(requestableSeasons));
}
}, [modalState?.seasons]);
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
return () => {
store.set(tvSeasonSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Sort seasons by season number (ascending)
const sortedSeasons = useMemo(() => {
if (!modalState?.seasons) return [];
return orderBy(
modalState.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"asc",
);
}, [modalState?.seasons]);
// Find the index of the first requestable season for initial focus
const firstRequestableIndex = useMemo(() => {
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
}, [sortedSeasons]);
const handleToggleSeason = useCallback((seasonNumber: number) => {
setSelectedSeasons((prev) => {
const newSet = new Set(prev);
if (newSet.has(seasonNumber)) {
newSet.delete(seasonNumber);
} else {
newSet.add(seasonNumber);
}
return newSet;
});
}, []);
const handleRequestSelected = useCallback(() => {
if (!modalState || selectedSeasons.size === 0) return;
const seasonsArray = Array.from(selectedSeasons);
const body: MediaRequestBody = {
mediaId: modalState.mediaId,
mediaType: MediaType.TV,
tvdbId: modalState.tvdbId,
seasons: seasonsArray,
};
if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal
router.back();
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
});
return;
}
// Build the title based on selected seasons
const seasonTitle =
seasonsArray.length === 1
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
: seasonsArray.length === sortedSeasons.length
? t("jellyseerr.season_all")
: t("jellyseerr.n_selected", { count: seasonsArray.length });
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
modalState.onRequested();
router.back();
});
}, [
modalState,
selectedSeasons,
sortedSeasons.length,
requestMedia,
router,
t,
showRequestModal,
]);
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.select_seasons")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{/* Season cards horizontal scroll */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{sortedSeasons.map((season, index) => {
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
return (
<TVSeasonToggleCard
key={season.id}
season={season}
selected={selectedSeasons.has(season.seasonNumber)}
onToggle={() => handleToggleSeason(season.seasonNumber)}
canRequest={canRequestSeason}
hasTVPreferredFocus={index === firstRequestableIndex}
/>
);
})}
</ScrollView>
{/* Request button */}
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequestSelected}
variant='secondary'
disabled={selectedSeasons.size === 0}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={[styles.buttonText, { fontSize: typography.callout }]}
>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingVertical: 12,
paddingHorizontal: 4,
gap: 16,
},
seasonCard: {
width: 160,
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
checkmarkContainer: {
height: 24,
marginBottom: 8,
},
seasonInfo: {
flex: 1,
},
seasonTitle: {
fontWeight: "600",
marginBottom: 4,
},
episodeRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {
fontSize: 14,
},
statusBadge: {
width: 22,
height: 22,
borderRadius: 11,
justifyContent: "center",
alignItems: "center",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -0,0 +1,190 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton, TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import { store } from "@/utils/store";
export default function TVSeriesSeasonModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
if (!modalState?.seasons) return 0;
const idx = modalState.seasons.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [modalState?.seasons]);
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvSeriesSeasonModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Focus on the selected card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (seasonIndex: number) => {
if (modalState?.onSeasonSelect) {
modalState.onSeasonSelect(seasonIndex);
}
router.back();
};
const handleCancel = () => {
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{t("item_card.select_season")}
</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.seasons.map((season, index) => (
<TVOptionCard
key={season.value}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={season.label}
selected={season.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(season.value)}
width={180}
height={85}
/>
))}
</ScrollView>
)}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={handleCancel}
label={t("common.cancel")}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import useRouter from "@/hooks/useAppRouter";
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
import type { SavedServerAccount } from "@/utils/secureCredentials";
import { store } from "@/utils/store";
export default function TVUserSwitchModalPage() {
const { t } = useTranslation();
const router = useRouter();
const modalState = useAtomValue(tvUserSwitchModalAtom);
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvUserSwitchModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (account: SavedServerAccount) => {
modalState?.onAccountSelect(account);
store.set(tvUserSwitchModalAtom, null);
router.back();
};
// If no modal state, just return null
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>
{t("home.settings.switch_user.title")}
</Text>
<Text style={styles.subtitle}>{modalState.serverName}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.accounts.map((account, index) => {
const isCurrent = account.userId === modalState.currentUserId;
return (
<TVUserCard
key={account.userId}
ref={index === 0 ? firstCardRef : undefined}
username={account.username}
securityType={account.securityType}
hasTVPreferredFocus={index === 0}
isCurrent={isCurrent}
onPress={() => handleSelect(account)}
/>
);
})}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
subtitle: {
fontSize: 14,
color: "rgba(255,255,255,0.4)",
marginBottom: 16,
paddingHorizontal: 48,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
},
});

View File

@@ -2,17 +2,19 @@ import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query"; import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { InactivityProvider } from "@/providers/InactivityProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider"; import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import { import {
apiAtom, apiAtom,
@@ -54,15 +56,31 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native"; import { Appearance, LogBox } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
// Suppress harmless tvOS warning from react-native-gesture-handler
if (Platform.isTV) {
LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]);
}
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
// Disable strict mode warnings for reading shared values during render
configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false,
});
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
@@ -178,7 +196,7 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider> <JotaiProvider store={jotaiStore}>
<ActionSheetProvider> <ActionSheetProvider>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Layout /> <Layout />
@@ -232,6 +250,11 @@ function Layout() {
const _segments = useSegments(); const _segments = useSegments();
const router = useRouter(); const router = useRouter();
// Enable TV menu key interception so React Native handles it instead of tvOS
useEffect(() => {
enableTVMenuKeyInterception();
}, []);
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
@@ -252,22 +275,19 @@ function Layout() {
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push expo push token to plugin"),
); );
} else console.log("No token available"); }
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => { const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "default", name: "default",
}); });
// Create dedicated channel for download notifications // Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", { await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads", name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT, importance: Notifications.AndroidImportance.DEFAULT,
@@ -342,8 +362,8 @@ function Layout() {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`; url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season // summarized season notification for multiple episodes. Bring them to series season
} else { } else {
const seriesId = data.seriesId; const seriesId = data?.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data?.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`; url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else { } else {
@@ -375,86 +395,153 @@ function Layout() {
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: { dehydrateOptions: {
shouldDehydrateQuery: (query) => { shouldDehydrateQuery: (query) => {
// Only persist successful queries return (
return query.state.status === "success"; query.state.status === "success" && query.options.gcTime !== 0
);
}, },
}, },
}} }}
> >
<JellyfinProvider> <JellyfinProvider>
<ServerUrlProvider> <InactivityProvider>
<NetworkStatusProvider> <ServerUrlProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </WebSocketProvider>
<Stack.Screen </LogProvider>
name='(auth)/now-playing' </PlaySettingsProvider>
options={{ </NetworkStatusProvider>
headerShown: false, </ServerUrlProvider>
presentation: "modal", </InactivityProvider>
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
); );

View File

@@ -1,659 +1,13 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Platform } from "react-native";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { Login } from "@/components/login/Login";
import { Image } from "expo-image"; import { TVLogin } from "@/components/login/TVLogin";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const LoginPage: React.FC = () => {
username: z.string().min(1, t("login.username_required")), if (Platform.isTV) {
}); return <TVLogin />;
const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
});
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
/**
* A way to auto login based on a link
*/
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
// Wait for server setup and state updates to complete
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
// Show save account modal to choose security type
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
// Login without saving
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
// Server is already selected, go to credential entry
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
/**
* Checks the availability and validity of a Jellyfin server URL.
*
* This function attempts to connect to a Jellyfin server using the provided URL.
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
*
* @param {string} url - The base URL of the Jellyfin server to check.
* @returns {Promise<string | undefined>} A Promise that resolves to:
* - The full URL (including protocol) if a valid Jellyfin server is found.
* - undefined if no valid server is found at the given URL.
*
* Side effects:
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{
mode: "cors",
},
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
} }
/**
* Handles the connection attempt to a Jellyfin server.
*
* This function trims the input URL, checks its validity using the `checkUrl` function,
* and sets the server address if a valid connection is established.
*
* @param {string} url - The URL of the Jellyfin server to connect to.
*
* @returns {Promise<void>}
*
* Side effects:
* - Calls `checkUrl` to validate the server URL.
* - Shows an alert if the connection fails.
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
} catch {}
}, []);
const handleQuickConnect = async () => { return <Login />;
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
return Platform.isTV ? (
// TV layout
<SafeAreaView className='flex-1 bg-black'>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
{api?.basePath ? (
// ------------ Username/Password view ------------
<View className='flex-1 items-center justify-center'>
{/* Safe centered column with max width so TV doesnt stretch too far */}
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
<Text className='text-3xl font-bold text-white mb-1'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-500'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400 mb-6'>
{api.basePath}
</Text>
{/* Username */}
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({ ...prev, username: newValue }));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
{/* Password */}
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({ ...prev, password: newValue }));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
</View>
<View className='mt-3'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</View>
</View>
) : (
// ------------ Server connect view ------------
<View className='flex-1 items-center justify-center'>
<View className='w-[92%] max-w-[900px] -mt-2'>
<View className='items-center mb-1'>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 110, height: 110 }}
contentFit='contain'
/>
</View>
<Text className='text-white text-4xl font-bold text-center'>
Streamyfin
</Text>
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Full-width Input with clear focus ring */}
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
<View className='mt-4'>
<Button
onPress={async () => {
await handleConnect(serverURL);
}}
>
{t("server.connect_button")}
</Button>
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<JellyfinServerDiscovery
onServerSelect={async (server: any) => {
setServerURL(server.address);
if (server.serverName) setServerName(server.serverName);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
) : (
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 justify-center'>
<View className='px-4 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
}; };
export default Login; export default LoginPage;

33
app/topshelf/item.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfItemRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id, type } = useLocalSearchParams<{
id?: string;
type?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
if (type === "Series") {
router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
return;
}
router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
}, [id, rootNavigationState?.key, router, type]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

32
app/topshelf/play.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfPlayRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id } = useLocalSearchParams<{
id?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
const queryParams = new URLSearchParams({
itemId: id,
offline: "false",
});
router.replace(`/player/direct-player?${queryParams.toString()}`);
}, [id, rootNavigationState?.key, router]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

@@ -0,0 +1,251 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
import { store } from "@/utils/store";
// Action card component
const TVAccountActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
height: 60,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 24,
gap: 12,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountActionModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountActionModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
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();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountActionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const handleLogin = () => {
modalState?.onLogin();
router.back();
};
const handleDelete = () => {
modalState?.onDelete();
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Account username as title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{modalState.account.username}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVAccountActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={handleLogin}
/>
<TVAccountActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={handleDelete}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

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