Compare commits

..

184 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
Gauvain
0aa2dc5924 feat: Enables iOS TV builds (#1422) 2026-05-22 08:49:15 +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
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (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
2026-05-22 01:32:27 +02:00
renovate[bot]
da9afacbf7 chore(deps): Update dependency react-i18next to v17 (#1574) 2026-05-22 01:07:52 +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
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
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
Gallyam Biktashev
479e23f001 fix(i18n): Update transalation for Russian lang (#1427)
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
🌐 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 18:51:01 +01:00
github-actions[bot]
019f863b3e feat: New Crowdin Translations (#1346)
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
🌐 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: Crowdin Bot <support+bot@crowdin.com>
2026-02-19 07:45:11 +01:00
renovate[bot]
54d8693999 chore(deps): Update crowdin/github-action action to v2.14.1 (#1453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 07:44:30 +01:00
quang-tran
8c0cbffd98 fix(logs): change the mimeType and UTI to the proper types for log ex… (#1424) 2026-02-19 07:39:29 +01:00
Alex
9f9d949891 feat: Re-add native apple controls for mpv (#1411) 2026-02-19 07:38:01 +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
renovate[bot]
8a782fb0c9 chore(deps): Update dependency @react-native-community/cli to v20.1.1 (#1413)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (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
🏷️🔀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: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:38 +01:00
renovate[bot]
7d0b6c37b8 chore(deps): Update dependency react-i18next to v16.5.4 (#1414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:26:01 +01:00
renovate[bot]
5443222e4e chore(deps): Update actions/cache action to v5.0.2 (#1397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:31 +01:00
renovate[bot]
a77e86be21 chore(deps): Update github/codeql-action action to v4.32.0 (#1410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:22 +01:00
renovate[bot]
9d82549cd6 chore(deps): Update dependency @tanstack/react-query to v5.90.20 (#1405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:12 +01:00
renovate[bot]
cb5cb8bff7 chore(deps): Update dependency lodash to v4.17.23 [SECURITY] (#1394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:25:02 +01:00
renovate[bot]
0b61fb7d98 chore(deps): Update peter-evans/create-pull-request action to v8.1.0 (#1401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:53 +01:00
renovate[bot]
f762b9017f chore(deps): Update crowdin/github-action action to v2.14.0 (#1400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:43 +01:00
renovate[bot]
b063fd6405 chore(deps): Update oven-sh/setup-bun action to v2.1.2 (#1399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:35 +01:00
renovate[bot]
88ac94ebeb chore(deps): Update actions/checkout action to v6.0.2 (#1398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 17:24:24 +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
358e00d8b7 fix(player): resolve tvOS freeze on player exit by reordering mpv options
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-01-19 08:41:52 +01:00
288 changed files with 18622 additions and 7459 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
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
3. For each new fact:
- Write it concisely (1-2 sentences max)
- Include context for why it matters
- Add today's date
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md`
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:
```
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
Create each file at `.claude/learned-facts/[kebab-case-name].md`:
```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)_
- **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)_
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`:
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
@@ -40,4 +43,6 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **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)_
- **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`.

19
.github/renovate.json vendored
View File

@@ -25,6 +25,25 @@
"osvVulnerabilityAlerts": true,
"configMigration": 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": {
"vulnerabilityAlerts": {
"enabled": true,

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Check if we're running from a fork (more precise detection)
@@ -188,6 +188,17 @@ jobs:
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -216,13 +227,6 @@ jobs:
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
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
@@ -236,7 +240,9 @@ jobs:
conclusion: job.conclusion,
url: job.html_url,
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'})`);
} else {
@@ -247,22 +253,30 @@ jobs:
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
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) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
// Fallback to workflow-level status for every build target.
// Keys must match jobMappings / buildTargets statusKey values.
const fallbackStatus = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
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
@@ -353,10 +367,12 @@ jobs:
// Process each expected build target individually
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS 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) {
@@ -371,16 +387,31 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled
if (target.name === 'iOS TV') {
// tvOS builds are temporarily disabled until feat/tv-interface
// is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
status = '💤 Disabled';
downloadLink = '*Disabled for now*';
downloadLink = '*Disabled until feat/tv-interface is merged*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
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';
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') {
status = `❌ [Failed](${matchingStatus.url})`;
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`;

View File

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

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.bun/install/cache

View File

@@ -24,16 +24,16 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -23,12 +23,12 @@ jobs:
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_sources: true
upload_translations: true

View File

@@ -25,7 +25,7 @@ jobs:
env:
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)
with:
header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
```
- 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:
header: pr-title-lint-error
delete: true
@@ -51,13 +51,13 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -69,14 +69,14 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
@@ -100,19 +100,19 @@ jobs:
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

View File

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

4
.gitignore vendored
View File

@@ -61,6 +61,8 @@ expo-env.d.ts
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
/profiles/
certs/
# Version and Backup Files
/version-backup-*
@@ -71,4 +73,4 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
.claude/settings.local.json
.claude/

136
CLAUDE.md
View File

@@ -1,9 +1,39 @@
# CLAUDE.md
@.claude/learned-facts.md
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
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**:
- Global state uses Jotai atoms in `utils/atoms/`
- `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
- Server state uses React Query with `@tanstack/react-query`
@@ -128,6 +159,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones
- 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
@@ -138,13 +170,13 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- **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 overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
- **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 only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
**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 components**:
**Pattern for TV-specific pages and components**:
```typescript
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
@@ -164,99 +196,11 @@ 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 Selector Pattern (Dropdowns/Multi-select)
### TV Option Selectors and Focus Management
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
- Bottom sheet takes minimal screen space
- Focus-based navigation works reliably
**Key implementation details:**
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
```typescript
<View style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}>
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
{/* Content */}
</BlurView>
</View>
```
2. **Horizontal ScrollView with focusable cards**:
```typescript
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => { onSelect(option.value); onClose(); }}
// ...
/>
))}
</ScrollView>
```
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
```typescript
<Pressable
onPress={onPress}
onFocus={() => { setFocused(true); animateTo(1.05); }}
onBlur={() => { setFocused(false); animateTo(1); }}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
</Animated.View>
</Pressable>
```
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
### TV Focus Management for Overlays/Modals
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
```typescript
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
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)

View File

@@ -126,6 +126,10 @@ For the TV version suffix the npm commands with `:tv`.
`npm run prebuild: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
Need assistance or have any questions?

View File

@@ -6,6 +6,14 @@ module.exports = ({ config }) => {
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
}
// Only override googleServicesFile if env var is set

View File

@@ -2,13 +2,11 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.52.0",
"version": "0.54.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -38,8 +36,7 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 92,
"versionCode": 93,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -76,19 +73,21 @@
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.6",
"useFrameworks": "static"
"deploymentTarget": "16.4",
"useFrameworks": "static",
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
"kotlinVersion": "2.1.20",
"minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
@@ -135,10 +134,12 @@
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[
"./plugins/withGitPod.js",

View File

@@ -96,6 +96,25 @@ 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: () => (
<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
name='settings/playback-controls/page'
options={{

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

@@ -59,6 +59,18 @@ function SettingsMobile() {
<QuickConnect className='mb-4' />
<TouchableOpacity
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
>
<Text className='text-white font-bold text-base mb-1'>
{t("pairing.pair_with_phone_title")}
</Text>
<Text className='text-neutral-400 text-sm'>
{t("pairing.pair_with_phone_description")}
</Text>
</TouchableOpacity>
<View className='mb-4'>
<AppLanguageSelector />
</View>

View File

@@ -1,10 +1,15 @@
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 { ScrollView, View } from "react-native";
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,
@@ -17,28 +22,240 @@ import {
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
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 } = useJellyfin();
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 =
@@ -47,6 +264,9 @@ export default function SettingsTV() {
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(
@@ -138,26 +358,65 @@ export default function SettingsTV() {
[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.text_size_small"),
label: t("home.settings.appearance.display_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.text_size_default"),
label: t("home.settings.appearance.display_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.text_size_large"),
label: t("home.settings.appearance.display_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.text_size_extra_large"),
label: t("home.settings.appearance.display_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
@@ -165,6 +424,74 @@ export default function SettingsTV() {
[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);
@@ -188,9 +515,32 @@ export default function SettingsTV() {
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.text_size_default");
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 }}>
@@ -215,6 +565,31 @@ export default function SettingsTV() {
{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
@@ -228,7 +603,6 @@ export default function SettingsTV() {
updateSettings({ audioTranscodeMode: value }),
})
}
isFirst
/>
{/* Subtitles Section */}
@@ -255,26 +629,10 @@ export default function SettingsTV() {
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
{/* MPV Subtitles Section */}
<TVSectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.5,
0.1,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
@@ -283,7 +641,7 @@ export default function SettingsTV() {
}}
onIncrease={() => {
const newValue = Math.min(
2.0,
3.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
);
updateSettings({
@@ -382,20 +740,117 @@ export default function SettingsTV() {
"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.text_size")}
label={t("home.settings.appearance.display_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.text_size"),
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",
@@ -422,6 +877,20 @@ export default function SettingsTV() {
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
@@ -444,6 +913,37 @@ export default function SettingsTV() {
</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

@@ -61,7 +61,10 @@ export default function Page() {
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
await Sharing.shareAsync(logsFile.uri, {
mimeType: "text/plain",
UTI: "public.plain-text",
});
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {
@@ -85,12 +88,7 @@ export default function Page() {
}, [share, loading]);
return (
<View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex-1'>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id={orderFilterId}
@@ -114,7 +112,10 @@ export default function Page() {
multiple={true}
/>
</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'>
{filteredLogs?.map((log, 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 { MediaProvider } from "@/components/settings/MediaContext";
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 { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -26,6 +28,8 @@ export default function PlaybackControlsPage() {
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
<MpvBufferSettings />
<MpvVoSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}

View File

@@ -18,7 +18,7 @@ export default function page() {
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4'
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>

View File

@@ -18,7 +18,7 @@ export default function page() {
>
<DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='px-4'
className='p-4'
>
<KefinTweaksSettings />
</DisabledSetting>

View File

@@ -27,16 +27,11 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -60,11 +55,13 @@ const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
@@ -153,7 +150,7 @@ const page: React.FC = () => {
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
@@ -189,7 +186,7 @@ const page: React.FC = () => {
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series"],
includeItemTypes: ["Movie", "Series", "Season"],
});
return response.data || null;
@@ -291,23 +288,19 @@ const page: React.FC = () => {
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
</View>
);
},
[router],
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -1,13 +1,14 @@
import { Slot, Stack, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
} from "expo-router/js-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
} from "expo-router/react-navigation";
import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator();
@@ -19,6 +20,17 @@ export const Tab = withLayoutContext<
>(Navigator);
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 (
<>
<Stack.Screen options={{ title: "Live TV" }} />

View File

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

View File

@@ -11,6 +11,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
@@ -33,18 +34,13 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
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 { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -70,10 +66,12 @@ import {
} from "@/utils/atoms/filters";
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 searchParams = useLocalSearchParams() as {
@@ -85,6 +83,7 @@ const Page = () => {
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -108,6 +107,7 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
@@ -286,6 +286,8 @@ const Page = () => {
itemType = "Video";
} else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
}
const response = await getItemsApi(api).getItems({
@@ -305,6 +307,9 @@ const Page = () => {
tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined,
...(Platform.isTV && library.CollectionType === "playlists"
? { mediaTypes: ["Video"] }
: {}),
});
return response.data || null;
@@ -401,31 +406,82 @@ const Page = () => {
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 (
<View
<TVPosterCard
key={item.Id}
style={{
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router],
[router, showItemActions, api, typography],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -69,6 +70,7 @@ export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
@@ -303,6 +305,9 @@ export default function search() {
},
hideWhenScrolling: false,
autoFocus: false,
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
},
});
}, [navigation]);
@@ -607,6 +612,7 @@ export default function search() {
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}
@@ -935,7 +941,7 @@ export default function search() {
</Text>
</View>
) : 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) => (
<TouchableOpacity
onPress={() => {

View File

@@ -24,14 +24,12 @@ import {
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
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 { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import {
useDeleteWatchlist,
useRemoveFromWatchlist,
@@ -46,35 +44,12 @@ import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => (
<View style={{ marginTop: 12 }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
@@ -203,26 +178,18 @@ export default function WatchlistDetailScreen() {
};
return (
<View
<TVPosterCard
key={item.Id}
style={{
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
</View>
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
);
},
[router, typography],
[router, showItemActions, posterSizes.poster],
);
const renderItem = useCallback(

View File

@@ -3,15 +3,16 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
} from "expo-router/react-navigation";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -36,6 +37,9 @@ export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
return (
<View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' />
@@ -130,7 +134,7 @@ export default function TabLayout() {
tabBarItemHidden: !Platform.isTV,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>

View File

@@ -10,6 +10,7 @@ import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { File } from "expo-file-system";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
@@ -45,20 +46,22 @@ import {
} from "@/modules";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
@@ -105,6 +108,9 @@ export default function page() {
// when data updates, only when the provider initializes
const downloadedFiles = downloadUtils.getDownloadedItems();
// Inactivity timer controls (TV only)
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
@@ -149,6 +155,13 @@ export default function page() {
: BITRATES[0].value;
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>(
null,
);
@@ -209,12 +222,25 @@ export default function page() {
);
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
// const getInitialPlaybackTicks = useCallback((): number => {
// if (playbackPositionFromUrl) {
// 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;
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
}, [tracksReady]);
useEffect(() => {
const fetchItemData = async () => {
@@ -228,7 +254,12 @@ export default function page() {
setDownloadedItem(data);
}
} 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,
userId: user?.Id,
});
@@ -262,6 +293,7 @@ export default function page() {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
requiredHttpHeaders?: Record<string, string>;
}
const [stream, setStream] = useState<Stream | null>(null);
@@ -324,7 +356,7 @@ export default function page() {
deviceProfile: generateDeviceProfile(),
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
@@ -333,7 +365,7 @@ export default function page() {
);
return null;
}
result = { mediaSource, sessionId, url };
result = { mediaSource, sessionId, url, requiredHttpHeaders };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
@@ -420,7 +452,9 @@ export default function page() {
setIsPlaybackStopped(true);
videoRef.current?.pause();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
// Resume inactivity timer when leaving player (TV only)
resumeInactivityTimer();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -530,10 +564,30 @@ 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) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
@@ -587,6 +641,15 @@ export default function page() {
autoplay: true,
initialSubtitleId,
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
@@ -594,17 +657,32 @@ export default function page() {
source.externalSubtitles = externalSubs;
}
// Add auth headers only for online streaming (not for local file:// URLs)
if (!offline && api?.accessToken) {
source.headers = {
Authorization: `MediaBrowser Token="${api.accessToken}"`,
};
// Add headers for online streaming (not for local file:// URLs)
if (!offline) {
const headers: Record<string, string> = {};
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;
}, [
stream?.url,
stream?.mediaSource,
stream?.requiredHttpHeaders,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
@@ -612,6 +690,11 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
settings.mpvCacheEnabled,
settings.mpvCacheSeconds,
settings.mpvDemuxerMaxBytes,
settings.mpvDemuxerMaxBackBytes,
settings.mpvVoDriver,
]);
const volumeUpCb = useCallback(async () => {
@@ -702,23 +785,27 @@ export default function page() {
setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
// Pause inactivity timer during playback (TV only)
pauseInactivityTimer();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
await activateKeepAwakeAsync();
return;
}
if (isPaused) {
setIsPlaying(false);
// Resume inactivity timer when paused (TV only)
resumeInactivityTimer();
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
);
}
if (!Platform.isTV) await deactivateKeepAwake();
await deactivateKeepAwake();
return;
}
@@ -726,7 +813,13 @@ export default function page() {
setIsBuffering(isLoading);
}
},
[playbackManager, item?.Id, progress],
[
playbackManager,
item?.Id,
progress,
pauseInactivityTimer,
resumeInactivityTimer,
],
);
/** PiP handler for MPV */
@@ -974,7 +1067,7 @@ export default function page() {
// TV: Navigate to next item
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
if (!nextItem || !settings || isPlaybackStopped) return;
const {
mediaSource: newMediaSource,
@@ -1007,6 +1100,7 @@ export default function page() {
stream?.mediaSource,
bitrateValue,
router,
isPlaybackStopped,
]);
// Apply subtitle settings when video loads
@@ -1028,14 +1122,27 @@ export default function page() {
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation)
// mpv uses #RRGGBBAA format (alpha last, same as CSS)
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}`,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
// Force override ASS subtitle styles so background shows on styled subtitles
await videoRef.current?.setSubtitleAssOverride?.("force");
} else {
// 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");
}
};
@@ -1056,6 +1163,28 @@ export default function page() {
applyInitialPlaybackSpeed();
}, [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
if (itemStatus.isError || streamStatus.isError) {
return (
@@ -1115,6 +1244,7 @@ export default function page() {
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
@@ -1180,6 +1310,7 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
) : (
<Controls

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
@@ -11,13 +11,17 @@ import {
} 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);
@@ -76,12 +80,25 @@ export default function TVOptionModal() {
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, cardWidth = 160, cardHeight = 75 } = modalState;
const { title, options } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
@@ -100,7 +117,9 @@ export default function TVOptionModal() {
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{title}
</Text>
{isReady && (
<ScrollView
horizontal
@@ -119,8 +138,8 @@ export default function TVOptionModal() {
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)}
width={cardWidth}
height={cardHeight}
width={scaledCardWidth}
height={scaledCardHeight}
/>
))}
</ScrollView>
@@ -142,21 +161,20 @@ const styles = StyleSheet.create({
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingTop: scaleSize(24),
paddingBottom: scaleSize(50),
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
marginBottom: scaleSize(16),
paddingHorizontal: scaleSize(48),
textTransform: "uppercase",
letterSpacing: 1,
},
@@ -164,8 +182,8 @@ const styles = StyleSheet.create({
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(20),
gap: scaleSize(12),
},
});

View File

@@ -22,14 +22,17 @@ import {
import { Text } from "@/components/common/Text";
import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
} from "@/hooks/useRemoteSubtitles";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { useSettings } from "@/utils/atoms/settings";
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
type TabType = "tracks" | "download" | "settings";
@@ -72,10 +75,10 @@ const TVTrackCard = React.forwardRef<
<Text
style={[
styles.trackCardText,
{ color: focused ? "#000" : "#fff" },
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(16) },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
numberOfLines={3}
>
{label}
</Text>
@@ -83,7 +86,10 @@ const TVTrackCard = React.forwardRef<
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]}
numberOfLines={1}
>
@@ -94,7 +100,7 @@ const TVTrackCard = React.forwardRef<
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
size={scaleSize(16)}
color='rgba(255,255,255,0.8)'
/>
</View>
@@ -142,7 +148,7 @@ const LanguageCard = React.forwardRef<
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
@@ -152,7 +158,10 @@ const LanguageCard = React.forwardRef<
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(11),
},
]}
>
{code.toUpperCase()}
@@ -161,7 +170,7 @@ const LanguageCard = React.forwardRef<
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
size={scaleSize(16)}
color='rgba(255,255,255,0.8)'
/>
</View>
@@ -219,7 +228,10 @@ const SubtitleResultCard = React.forwardRef<
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
{
color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)",
fontSize: scaleSize(11),
},
]}
>
{result.providerName}
@@ -228,7 +240,10 @@ const SubtitleResultCard = React.forwardRef<
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
style={[
styles.resultName,
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(14) },
]}
numberOfLines={2}
>
{result.name}
@@ -240,7 +255,10 @@ const SubtitleResultCard = React.forwardRef<
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]}
>
{result.format?.toUpperCase()}
@@ -252,7 +270,7 @@ const SubtitleResultCard = React.forwardRef<
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
size={scaleSize(12)}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
@@ -262,6 +280,7 @@ const SubtitleResultCard = React.forwardRef<
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]}
>
@@ -275,7 +294,7 @@ const SubtitleResultCard = React.forwardRef<
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
size={scaleSize(12)}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
@@ -285,6 +304,7 @@ const SubtitleResultCard = React.forwardRef<
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
fontSize: scaleSize(12),
},
]}
>
@@ -307,7 +327,9 @@ const SubtitleResultCard = React.forwardRef<
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
Hash Match
</Text>
</View>
)}
{result.hearingImpaired && (
@@ -323,7 +345,7 @@ const SubtitleResultCard = React.forwardRef<
>
<Ionicons
name='ear-outline'
size={12}
size={scaleSize(12)}
color={focused ? "#000" : "#fff"}
/>
</View>
@@ -339,7 +361,9 @@ const SubtitleResultCard = React.forwardRef<
},
]}
>
<Text style={styles.flagText}>AI</Text>
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
AI
</Text>
</View>
)}
</View>
@@ -389,7 +413,7 @@ const TVStepperButton: React.FC<{
>
<Ionicons
name={icon}
size={28}
size={scaleSize(28)}
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
/>
</Animated.View>
@@ -485,7 +509,7 @@ const TVAlignmentCard: React.FC<{
<Text
style={[
styles.alignmentCardText,
{ color: focused ? "#000" : "#fff" },
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
(focused || selected) && { fontWeight: "600" },
]}
>
@@ -495,7 +519,7 @@ const TVAlignmentCard: React.FC<{
<View style={styles.alignmentCheckmark}>
<Ionicons
name='checkmark'
size={14}
size={scaleSize(14)}
color='rgba(255,255,255,0.8)'
/>
</View>
@@ -510,6 +534,7 @@ export default function TVSubtitleModal() {
const { t } = useTranslation();
const modalState = useAtomValue(tvSubtitleModalAtom);
const { settings, updateSettings } = useSettings();
const typography = useScaledTVTypography();
const [activeTab, setActiveTab] = useState<TabType>("tracks");
const [selectedLanguage, setSelectedLanguage] = useState("eng");
@@ -604,6 +629,12 @@ export default function TVSubtitleModal() {
router.back();
}, [router]);
// Intercept back/menu press to close the modal instead of the player
useTVBackPress(() => {
handleClose();
return true;
}, [handleClose]);
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
@@ -659,8 +690,30 @@ export default function TVSubtitleModal() {
// Do NOT close modal - user can see and select the new track
} else if (downloadResult.type === "local" && downloadResult.path) {
// Notify parent that a local subtitle was downloaded
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
handleClose(); // Only close for local downloads
// Check if component is still mounted after callback
if (!isMountedRef.current) return;
// Refresh tracks to include the newly downloaded subtitle
if (modalState?.refreshSubtitleTracks) {
const newTracks = await modalState.refreshSubtitleTracks();
// Check if component is still mounted after fetching tracks
if (!isMountedRef.current) return;
// Update atom with new tracks
store.set(tvSubtitleModalAtom, {
...modalState,
subtitleTracks: newTracks,
});
// Switch to tracks tab to show the new subtitle
setActiveTab("tracks");
} else {
// No refreshSubtitleTracks available (e.g., from player), just close
handleClose();
}
}
} catch (error) {
console.error("Failed to download subtitle:", error);
@@ -685,13 +738,17 @@ export default function TVSubtitleModal() {
value: -1,
selected: currentSubtitleIndex === -1,
setTrack: () => modalState?.onDisableSubtitles?.(),
isLocal: false,
};
const options = subtitleTracks.map((track: Track) => ({
label: track.name,
sublabel: undefined as string | undefined,
sublabel: track.isLocal
? t("player.downloaded") || "Downloaded"
: (undefined as string | undefined),
value: track.index,
selected: track.index === currentSubtitleIndex,
setTrack: track.setTrack,
isLocal: track.isLocal ?? false,
}));
return [noneOption, ...options];
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
@@ -719,7 +776,7 @@ export default function TVSubtitleModal() {
>
{/* Header with tabs */}
<View style={styles.header}>
<Text style={styles.title}>
<Text style={[styles.title, { fontSize: typography.heading }]}>
{t("item_card.subtitles.label") || "Subtitles"}
</Text>
@@ -776,7 +833,9 @@ export default function TVSubtitleModal() {
<>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
<Text
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
>
{t("player.language") || "Language"}
</Text>
<ScrollView
@@ -803,7 +862,9 @@ export default function TVSubtitleModal() {
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
<Text
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
@@ -820,13 +881,17 @@ export default function TVSubtitleModal() {
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
size={scaleSize(32)}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
<Text
style={[styles.errorText, { fontSize: scaleSize(16) }]}
>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
<Text
style={[styles.errorHint, { fontSize: scaleSize(13) }]}
>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
@@ -843,10 +908,15 @@ export default function TVSubtitleModal() {
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
size={scaleSize(32)}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
<Text
style={[
styles.emptyText,
{ fontSize: scaleSize(14) },
]}
>
{t("player.no_subtitles_found") ||
"No subtitles found"}
</Text>
@@ -881,10 +951,15 @@ export default function TVSubtitleModal() {
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
size={scaleSize(16)}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
<Text
style={[
styles.apiKeyHintText,
{ fontSize: scaleSize(12) },
]}
>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
@@ -905,8 +980,8 @@ export default function TVSubtitleModal() {
<View style={styles.settingRow}>
<TVStepperControl
value={settings.mpvSubtitleScale ?? 1.0}
min={0.5}
max={2.0}
min={0.1}
max={3.0}
step={0.1}
formatValue={(v) => `${v.toFixed(1)}x`}
onChange={(newValue) => {
@@ -916,7 +991,12 @@ export default function TVSubtitleModal() {
}}
hasTVPreferredFocus={true}
/>
<Text style={styles.settingLabel}>
<Text
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_scale") ||
"Subtitle Scale"}
</Text>
@@ -934,7 +1014,12 @@ export default function TVSubtitleModal() {
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<Text style={styles.settingLabel}>
<Text
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_margin_y") ||
"Vertical Margin"}
</Text>
@@ -958,7 +1043,12 @@ export default function TVSubtitleModal() {
/>
))}
</View>
<Text style={styles.settingLabel}>
<Text
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_align_x") ||
"Horizontal Align"}
</Text>
@@ -982,7 +1072,12 @@ export default function TVSubtitleModal() {
/>
))}
</View>
<Text style={styles.settingLabel}>
<Text
style={[
styles.settingLabel,
{ fontSize: typography.callout },
]}
>
{t("home.settings.subtitles.mpv_subtitle_align_y") ||
"Vertical Align"}
</Text>
@@ -1007,218 +1102,201 @@ const styles = StyleSheet.create({
maxHeight: "70%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 48,
paddingTop: scaleSize(24),
paddingBottom: scaleSize(48),
},
header: {
paddingHorizontal: 48,
marginBottom: 20,
paddingHorizontal: scaleSize(48),
marginBottom: scaleSize(20),
},
title: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
marginBottom: scaleSize(16),
},
tabRow: {
flexDirection: "row",
gap: 24,
gap: scaleSize(24),
},
section: {
marginBottom: 20,
marginBottom: scaleSize(20),
},
sectionTitle: {
fontSize: 14,
fontWeight: "500",
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 12,
paddingHorizontal: 48,
marginBottom: scaleSize(12),
paddingHorizontal: scaleSize(48),
},
tracksScroll: {
overflow: "visible",
},
tracksScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(8),
gap: scaleSize(12),
},
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
width: scaleSize(180),
height: scaleSize(80),
borderRadius: scaleSize(14),
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
paddingHorizontal: scaleSize(12),
},
trackCardText: {
fontSize: 16,
textAlign: "center",
},
trackCardSublabel: {
fontSize: 12,
marginTop: 2,
marginTop: scaleSize(2),
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
top: scaleSize(8),
right: scaleSize(8),
},
languageScroll: {
overflow: "visible",
},
languageScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 10,
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(8),
gap: scaleSize(10),
},
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
width: scaleSize(120),
height: scaleSize(60),
borderRadius: scaleSize(12),
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
paddingHorizontal: scaleSize(12),
},
languageCardText: {
fontSize: 15,
fontWeight: "500",
},
languageCardCode: {
fontSize: 11,
marginTop: 2,
marginTop: scaleSize(2),
},
resultsScroll: {
overflow: "visible",
},
resultsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 12,
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(8),
gap: scaleSize(12),
},
resultCard: {
width: 220,
height: 130,
borderRadius: 14,
padding: 14,
width: scaleSize(220),
height: scaleSize(130),
borderRadius: scaleSize(14),
padding: scaleSize(14),
borderWidth: 1,
overflow: "hidden",
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
paddingHorizontal: scaleSize(8),
paddingVertical: scaleSize(3),
borderRadius: scaleSize(6),
marginBottom: scaleSize(8),
},
providerText: {
fontSize: 11,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: 14,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
marginBottom: scaleSize(8),
lineHeight: scaleSize(18),
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: 12,
gap: scaleSize(12),
marginBottom: scaleSize(8),
},
resultMetaText: {},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
gap: scaleSize(3),
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
gap: scaleSize(3),
},
flagsContainer: {
flexDirection: "row",
gap: 6,
gap: scaleSize(6),
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
paddingHorizontal: scaleSize(6),
paddingVertical: scaleSize(2),
borderRadius: scaleSize(4),
},
flagText: {
fontSize: 10,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
borderRadius: scaleSize(14),
justifyContent: "center",
alignItems: "center",
},
loadingContainer: {
paddingVertical: 20,
paddingVertical: scaleSize(20),
alignItems: "center",
},
errorContainer: {
paddingVertical: 40,
paddingHorizontal: 48,
paddingVertical: scaleSize(40),
paddingHorizontal: scaleSize(48),
alignItems: "center",
},
errorText: {
color: "rgba(255,100,100,0.9)",
marginTop: 8,
fontSize: 16,
marginTop: scaleSize(8),
fontWeight: "500",
},
errorHint: {
color: "rgba(255,255,255,0.5)",
marginTop: 4,
fontSize: 13,
marginTop: scaleSize(4),
textAlign: "center",
},
emptyContainer: {
paddingVertical: 40,
paddingVertical: scaleSize(40),
alignItems: "center",
},
emptyText: {
color: "rgba(255,255,255,0.5)",
marginTop: 8,
fontSize: 14,
marginTop: scaleSize(8),
},
apiKeyHint: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 48,
paddingTop: 8,
},
apiKeyHintText: {
color: "rgba(255,255,255,0.4)",
fontSize: 12,
gap: scaleSize(8),
paddingHorizontal: scaleSize(48),
paddingTop: scaleSize(8),
},
apiKeyHintText: {},
// Settings tab styles
settingsScroll: {
maxHeight: 300,
maxHeight: scaleSize(300),
},
settingsScrollContent: {
paddingHorizontal: 48,
paddingVertical: 8,
gap: 24,
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(8),
gap: scaleSize(24),
},
settingRow: {
flexDirection: "row",
@@ -1226,49 +1304,47 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
},
settingLabel: {
fontSize: 18,
fontWeight: "500",
color: "#fff",
},
sizeControlContainer: {
flexDirection: "row",
alignItems: "center",
gap: 16,
gap: scaleSize(16),
},
stepperButton: {
width: 56,
height: 56,
borderRadius: 14,
width: scaleSize(56),
height: scaleSize(56),
borderRadius: scaleSize(14),
justifyContent: "center",
alignItems: "center",
},
sizeValueContainer: {
width: 80,
width: scaleSize(80),
alignItems: "center",
},
sizeValueText: {
fontSize: 24,
fontWeight: "600",
color: "#fff",
fontSize: scaleSize(24),
},
alignmentRow: {
flexDirection: "row",
gap: 10,
gap: scaleSize(10),
},
alignmentCard: {
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 12,
minWidth: 90,
paddingHorizontal: scaleSize(20),
paddingVertical: scaleSize(14),
borderRadius: scaleSize(12),
minWidth: scaleSize(90),
alignItems: "center",
},
alignmentCardText: {
fontSize: 15,
textTransform: "capitalize",
},
alignmentCheckmark: {
position: "absolute",
top: 6,
right: 6,
top: scaleSize(6),
right: scaleSize(6),
},
});

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,18 +2,19 @@ import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { InactivityProvider } from "@/providers/InactivityProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
@@ -55,15 +56,31 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
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 { 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 { userAtom } from "@/providers/JellyfinProvider";
import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import { Toaster } from "sonner-native";
// Disable strict mode warnings for reading shared values during render
configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false,
});
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
@@ -233,6 +250,11 @@ function Layout() {
const _segments = useSegments();
const router = useRouter();
// Enable TV menu key interception so React Native handles it instead of tvOS
useEffect(() => {
enableTVMenuKeyInterception();
}, []);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
@@ -253,22 +275,19 @@ function Layout() {
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}
}, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
@@ -343,8 +362,8 @@ function Layout() {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season
} else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
const seriesId = data?.seriesId;
const seasonIndex = data?.seasonIndex;
if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else {
@@ -383,119 +402,145 @@ function Layout() {
}}
>
<JellyfinProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<InactivityProvider>
<ServerUrlProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
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
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>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider>
</JellyfinProvider>
</PersistQueryClientProvider>
);

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

View File

@@ -0,0 +1,256 @@
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 { TVUserCard } from "@/components/tv/TVUserCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import { store } from "@/utils/store";
// Action button for bottom sheet
const TVAccountSelectAction: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
onPress: () => void;
}> = ({ label, icon, variant = "default", 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);
}}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
minHeight: 72,
gap: 14,
}}
>
<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 TVAccountSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountSelectModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
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",
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{t("server.select_account")}
</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>
{/* All options in single horizontal row */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
}}
>
{modalState.server.accounts?.map((account, index) => (
<TVUserCard
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => {
modalState.onAccountAction(account);
}}
hasTVPreferredFocus={index === 0}
/>
))}
<TVAccountSelectAction
label={t("server.add_account")}
icon='person-add-outline'
onPress={() => {
modalState.onAddAccount();
router.back();
}}
/>
<TVAccountSelectAction
label={t("server.remove_server")}
icon='trash-outline'
variant='destructive'
onPress={() => {
modalState.onDeleteServer();
router.back();
}}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

BIN
assets/icons/gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -11,7 +11,7 @@ Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes";
const k = 1024;
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

View File

@@ -8,6 +8,8 @@
"!android",
"!Streamyfin.app",
"!utils/jellyseerr",
"!expo-env.d.ts",
"!modules/**/android/build",
"!.expo",
"!docs/jellyfin-openapi-stable.json"
]

View File

@@ -1,21 +1,26 @@
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
- #if !os(macOS)
+ #if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
@@ -67,11 +67,11 @@
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
func body(content: Content) -> some View {
- #if os(macOS)
- // tabViewBottomAccessory is not available on macOS
@@ -28,8 +33,8 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
content
.tabViewBottomAccessory {
renderBottomAccessoryView()
@@ -84,7 +84,7 @@
@@ -95,7 +95,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
@ViewBuilder
private func renderBottomAccessoryView() -> some View {
- #if !os(macOS)
@@ -37,20 +42,26 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
if let bottomAccessoryView {
if #available(iOS 26.0, *) {
BottomAccessoryRepresentableView(view: bottomAccessoryView)
@@ -94,7 +94,7 @@
@@ -105,7 +105,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
}
-#if !os(macOS)
+#if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *)
struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@
@@ -135,3 +135,4 @@ struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
}
}
#endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View {
- #if compiler(>=6.2)
@@ -58,13 +69,14 @@ diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
@@ -6,7 +6,7 @@
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown
-#if compiler(>=6.2)
+#if compiler(>=6.2) && !os(tvOS)
@available(iOS 26.0, macOS 26.0, *)

View File

@@ -0,0 +1,28 @@
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
@@ -25,15 +25,14 @@ public extension RCTView {
return rootView.recursivelyFindSubview(whereType: targetType);
};
- var closestParentReactContentView: RCTRootContentView? {
- let targetType = RCTRootContentView.self;
-
- if let match = self.recursivelyFindParentView(whereType: targetType) {
- return match;
- };
-
- guard let rootView = self.rootViewForCurrentWindow else { return nil };
- return rootView.recursivelyFindSubview(whereType: targetType);
+ // PATCH (streamyfin): RCTRootContentView is a legacy paper class that the prebuilt
+ // new-architecture React (RN 0.85) does not export, so any reference to it fails to
+ // link (Undefined symbols: _OBJC_CLASS_$_RCTRootContentView). The app runs the new
+ // architecture, where this content-view lookup is unused; short-circuit to nil.
+ // Return type widened to RCTView? so the caller's `.reactTouchHandlers` (an RCTView
+ // extension) still resolves.
+ var closestParentReactContentView: RCTView? {
+ return nil;
};
var reactTouchHandlers: [RCTTouchHandler]? {

View File

@@ -0,0 +1,17 @@
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]
s.author = { package_json["author"] => package_json["author"] }
- s.platform = :ios, "7.0"
+ s.ios.deployment_target = "7.0"
+ s.tvos.deployment_target = "15.1"
s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" }
s.source_files = 'ios/**/*.{h,m}'
s.dependency 'React-Core'

2068
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import {
View,
} from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { scaleSize } from "@/utils/scaleSize";
import { Loader } from "./Loader";
const getColorClasses = (
@@ -132,19 +133,29 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View
style={{
transform: [{ scale }],
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowColor: "#ffffff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 10 : 0,
shadowRadius: focused ? scaleSize(10) : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>
<View
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${className}`}
style={{
borderRadius: scaleSize(16),
paddingVertical: scaleSize(14),
alignItems: "center",
justifyContent: "center",
}}
className={`${colorClasses} ${className}`}
>
<Text className={`${textColorClass} text-xl font-bold`}>
<Text
style={{
fontSize: scaleSize(20),
fontWeight: "bold",
}}
className={textColorClass}
>
{children}
</Text>
</View>

View File

@@ -1,188 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 400;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item, useEpisodePoster]);
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
const isWatched = item.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (!url) {
return (
<View
style={{
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
if (useGlass) {
return (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={url}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={TV_LANDSCAPE_WIDTH}
style={{ width: TV_LANDSCAPE_WIDTH }}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
);
}
// Fallback for older tvOS versions
return (
<View
style={{
position: "relative",
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

@@ -73,12 +73,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
SelectedOptions | undefined
>(undefined);
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(items[0], settings);
} = useDefaultPlaySettings(items[0], settings, playSettingsOptions);
const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading,

View File

@@ -75,12 +75,20 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
>(undefined);
// Use itemWithSources for play settings since it has MediaSources data
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),

View File

@@ -7,6 +7,7 @@ import type {
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { File } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, {
@@ -17,14 +18,14 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, View } from "react-native";
import { Alert, Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { TVEpisodeList } from "@/components/series/TVEpisodeList";
import {
TVBackdrop,
TVButton,
@@ -33,6 +34,7 @@ import {
TVFavoriteButton,
TVMetadataBadges,
TVOptionButton,
TVPlayedButton,
TVProgressBar,
TVRefreshButton,
TVSeriesNavigation,
@@ -43,15 +45,18 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { useTVThemeMusic } from "@/hooks/useTVThemeMusic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
@@ -78,11 +83,15 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const { t } = useTranslation();
const queryClient = useQueryClient();
const _itemColors = useImageColorsReturn({ item });
// Auto-play theme music (handles fade in/out and cleanup)
useTVThemeMusic(item?.Id);
// State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
@@ -112,12 +121,22 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
SelectedOptions | undefined
>(undefined);
// Enable language preference application for TV
const playSettingsOptions = useMemo(
() => ({ applyLanguagePreferences: true }),
[],
);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
} = useDefaultPlaySettings(
itemWithSources ?? item,
settings,
playSettingsOptions,
);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -139,21 +158,59 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
defaultMediaSource,
]);
const navigateToPlayer = useCallback(
(playbackPosition: string) => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition,
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[item, selectedOptions, isOffline, router],
);
const handlePlay = () => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const hasPlaybackProgress =
(item.UserData?.PlaybackPositionTicks ?? 0) > 0;
router.push(`/player/direct-player?${queryParams.toString()}`);
if (hasPlaybackProgress) {
Alert.alert(
t("item_card.resume_playback"),
t("item_card.resume_playback_description"),
[
{
text: t("common.cancel"),
style: "cancel",
},
{
text: t("item_card.play_from_start"),
onPress: () => navigateToPlayer("0"),
},
{
text: t("item_card.continue_from", {
time: formatDuration(item.UserData?.PlaybackPositionTicks),
}),
onPress: () =>
navigateToPlayer(
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
),
isPreferred: true,
},
],
);
} else {
navigateToPlayer("0");
}
};
// TV Option Modal hook for quality, audio, media source selectors
@@ -167,10 +224,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
null,
);
// State for last option button ref (used for upward focus guide from cast)
const [_lastOptionButtonRef, setLastOptionButtonRef] =
useState<View | null>(null);
// Get available audio tracks
const audioTracks = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
@@ -192,9 +245,16 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
null,
);
// State to trigger refresh of local subtitles list
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
// Starting index for local (client-downloaded) subtitles
const LOCAL_SUBTITLE_INDEX_START = -100;
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
// Also includes locally downloaded subtitles from OpenSubtitles
const subtitleTracksForModal = useMemo((): Track[] => {
return subtitleStreams.map((stream) => ({
const tracks: Track[] = subtitleStreams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -203,7 +263,37 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
}, [subtitleStreams]);
// Add locally downloaded subtitles (from OpenSubtitles)
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) {
continue;
}
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
// For ItemContent (outside player), just update the selected index
// The actual subtitle will be loaded when playback starts
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtitleStreams, item?.Id, localSubtitlesRefreshKey]);
// Get available media sources
const mediaSources = useMemo(() => {
@@ -295,6 +385,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [item?.Id, queryClient]);
// Handle local subtitle download - trigger refresh of subtitle tracks
const handleLocalSubtitleDownloaded = useCallback((_path: string) => {
// Increment the refresh key to trigger re-computation of subtitleTracksForModal
setLocalSubtitlesRefreshKey((prev) => prev + 1);
}, []);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
if (!api || !item?.Id) return [];
@@ -322,7 +418,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
) ?? [];
// Convert to Track[] with setTrack callbacks
return streams.map((stream) => ({
const tracks: Track[] = streams.map((stream) => ({
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
@@ -331,6 +427,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
},
}));
// Add locally downloaded subtitles
if (item?.Id) {
const localSubs = getSubtitlesForItem(item.Id);
let localIdx = 0;
for (const localSub of localSubs) {
const subtitleFile = new File(localSub.filePath);
if (!subtitleFile.exists) continue;
const localIndex = LOCAL_SUBTITLE_INDEX_START - localIdx;
tracks.push({
name: localSub.name,
index: localIndex,
isLocal: true,
localPath: localSub.filePath,
setTrack: () => {
handleSubtitleChangeRef.current?.(localIndex);
},
});
localIdx++;
}
}
return tracks;
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
@@ -348,13 +468,30 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none");
// Check if it's a local subtitle (negative index starting at -100)
if (
selectedOptions?.subtitleIndex !== undefined &&
selectedOptions.subtitleIndex <= LOCAL_SUBTITLE_INDEX_START
) {
const localTrack = subtitleTracksForModal.find(
(t) => t.index === selectedOptions.subtitleIndex,
);
return localTrack?.name || t("item_card.subtitles.label");
}
const track = subtitleStreams.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [subtitleStreams, selectedOptions?.subtitleIndex, t]);
}, [
subtitleStreams,
subtitleTracksForModal,
selectedOptions?.subtitleIndex,
t,
]);
const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource;
@@ -425,25 +562,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
if (hasSubtitleOption) return "subtitle";
if (hasAudioOption) return "audio";
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleStreams.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
// Navigation handlers
const handleActorPress = useCallback(
(personId: string) => {
@@ -469,7 +587,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)");
router.push(navigation as any);
router.replace(navigation as any);
},
[router],
);
@@ -634,27 +752,24 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
</TVButton>
<TVFavoriteButton item={item} />
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>
{/* Playback options */}
<View
style={{
flexDirection: "column",
alignItems: "flex-start",
gap: 10,
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 24,
}}
>
{/* Quality selector */}
<TVOptionButton
ref={
lastOptionButton === "quality"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.quality")}
value={selectedQualityLabel}
maxWidth={200}
onPress={() =>
showOptions({
title: t("item_card.quality"),
@@ -667,13 +782,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
ref={
lastOptionButton === "mediaSource"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.video")}
value={selectedMediaSourceLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.video"),
@@ -687,13 +798,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
ref={
lastOptionButton === "audio"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.audio")}
value={selectedAudioLabel}
maxWidth={280}
onPress={() =>
showOptions({
title: t("item_card.audio"),
@@ -708,13 +815,9 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{(subtitleStreams.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
maxWidth={280}
onPress={() =>
showSubtitleModal({
item,
@@ -725,6 +828,8 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
onDisableSubtitles: () => handleSubtitleChange(-1),
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
onLocalSubtitleDownloaded:
handleLocalSubtitleDownloaded,
refreshSubtitleTracks,
})
}
@@ -806,26 +911,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{t("item_card.more_from_this_season")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
}}
>
{seasonEpisodes.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={episode.Id === item.Id}
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))}
</ScrollView>
<TVEpisodeList
episodes={seasonEpisodes}
currentEpisodeId={item.Id}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
firstEpisodeRefSetter={setFirstEpisodeRef}
horizontalPadding={insets.left + 80}
/>
</View>
)}
@@ -845,6 +938,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
horizontalPadding={insets.left + 80}
/>
)}

View File

@@ -128,7 +128,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{/* Password Input */}
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
<Text className='text-neutral-400 text-sm mb-2'>
{t("login.password")}
{t("login.password_placeholder")}
</Text>
<BottomSheetTextInput
value={password}
@@ -136,7 +136,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
setPassword(text);
setError(null);
}}
placeholder={t("login.password")}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
secureTextEntry
autoFocus
@@ -174,7 +174,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
{isLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("login.login")
t("common.login")
)}
</Button>
</View>

View File

@@ -1,8 +1,21 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import {
Button,
Host,
Menu,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -201,6 +214,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -232,10 +263,24 @@ const PlatformDropdownComponent = ({
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
<ContextMenu.Items>
<View>
{/* Hidden measurer: lays the trigger out normally to capture its
intrinsic size, which we then pin onto the Host below. */}
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
<View
style={{ alignSelf: "flex-start" }}
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
</View>
<Host
style={[
triggerSize ?? { opacity: 0 },
expoUIConfig?.hostStyle as any,
]}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
@@ -254,23 +299,37 @@ const PlatformDropdownComponent = ({
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options
// Use Picker for grouped options.
// Use the option index (a stable primitive) as the
// tag/selection value and React key. Option `value`s can be
// objects (e.g. bitrate / media source), which collapse to
// "[object Object]" as a key and never match the Picker's
// primitive selection.
const selectedRadioIndex = radioOptions.findIndex(
(opt) => opt.selected,
);
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
options={radioOptions.map((opt) => opt.label)}
variant='menu'
selectedIndex={radioOptions.findIndex(
(opt) => opt.selected,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selection={
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
}
onSelectionChange={(index) => {
const selectedOption = radioOptions[index as number];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
/>,
>
{radioOptions.map((opt, optionIndex) => (
<SwiftUIText
key={`radio-${groupIndex}-${optionIndex}`}
modifiers={[tag(optionIndex)]}
>
{opt.label}
</SwiftUIText>
))}
</Picker>,
);
} else {
// Render radio options as direct buttons
@@ -278,17 +337,18 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
}
@@ -299,17 +359,16 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
@@ -318,21 +377,20 @@ const PlatformDropdownComponent = ({
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
disabled={option.disabled}
>
{option.label}
</Button>,
/>,
);
});
return items;
})}
</ContextMenu.Items>
</ContextMenu>
</Host>
</Menu>
</Host>
</View>
);
}

View File

@@ -35,9 +35,9 @@ import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { chromecast } from "../utils/profiles/chromecast";
import { chromecasth265 } from "../utils/profiles/chromecasth265";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";

View File

@@ -69,6 +69,7 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
const queryString = queryParams.toString();

View File

@@ -73,10 +73,19 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(server.address);
try {
await onQuickLogin(server.address, account.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
);
} finally {
@@ -122,10 +131,17 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setLoadingServer(selectedServer.address);
try {
await onQuickLogin(selectedServer.address, selectedAccount.userId);
} catch {
Alert.alert(
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
t("server.please_login_again"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
[
{
text: t("common.ok"),

View File

@@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
Platform,
TouchableOpacity,
type TouchableOpacityProps,
} from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
@@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
}
if (item.Type === "Playlist") {
if (Platform.isTV) {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
return {
pathname: "/music/playlist/[playlistId]" as const,
params: { playlistId: item.Id! },

View File

@@ -0,0 +1,532 @@
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
KeyboardAvoidingView,
Linking,
Platform,
ScrollView,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sendCredentialsToTV } from "@/utils/pairingService";
type ScreenState =
| "scanning"
| "no-permission"
| "confirm"
| "form"
| "sending"
| "success"
| "error";
interface ParsedPairingCode {
code: string;
}
type ExpoCameraModule = typeof import("expo-camera");
const ExpoCamera: ExpoCameraModule | null = Platform.isTV
? null
: require("expo-camera");
export const CompanionLoginScreen: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [screenState, setScreenState] = useState<ScreenState>(
Platform.isTV ? "form" : "scanning",
);
const [pairingCode, setPairingCode] = useState<string>("");
const [serverUrl, setServerUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Pre-fill server URL and username from current session
useEffect(() => {
if (api?.basePath) {
setServerUrl(api.basePath);
}
if (user?.Name) {
setUsername(user.Name);
}
}, [api?.basePath, user?.Name]);
// Request camera permission
useEffect(() => {
if (!ExpoCamera) return;
ExpoCamera.Camera.getCameraPermissionsAsync().then((response) => {
if (!response.granted) {
ExpoCamera.Camera.requestCameraPermissionsAsync().then((result) => {
if (!result.granted) {
setScreenState("no-permission");
}
});
}
});
}, []);
const validateAndParseQR = useCallback(
(data: string): ParsedPairingCode | null => {
try {
const parsed = JSON.parse(data);
if (
parsed.action === "streamyfin-pair" &&
typeof parsed.code === "string" &&
parsed.code.length > 0
) {
return { code: parsed.code };
}
return null;
} catch {
return null;
}
},
[],
);
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (screenState !== "scanning") return;
const parsed = validateAndParseQR(data);
if (!parsed) {
setErrorMessage(t("companion_login.error_invalid_qr"));
setScreenState("error");
return;
}
setPairingCode(parsed.code);
// If user is logged in, show confirmation screen (still needs password)
// Otherwise, go straight to the full form
if (user?.Name && api?.basePath) {
setScreenState("confirm");
} else {
setScreenState("form");
}
},
[screenState, validateAndParseQR, t, user?.Name, api?.basePath],
);
const handleSendCredentials = useCallback(async () => {
if (
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode
) {
return;
}
setScreenState("sending");
try {
await sendCredentialsToTV(
pairingCode,
serverUrl.trim(),
username.trim(),
password,
);
setScreenState("success");
} catch {
setErrorMessage(t("companion_login.error_generic"));
setScreenState("error");
}
}, [pairingCode, serverUrl, username, password, t]);
const handleScanAgain = useCallback(() => {
setPairingCode("");
setErrorMessage(null);
setPassword("");
setScreenState("scanning");
}, []);
const handleDone = useCallback(() => {
router.back();
}, [router]);
const handleUseDifferentUser = useCallback(() => {
setUsername("");
setPassword("");
setScreenState("form");
}, []);
const handleEnterCodeManually = useCallback(() => {
setScreenState("form");
}, []);
if (screenState === "no-permission") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_permission_denied")}
</Text>
{Platform.OS === "ios" && (
<TouchableOpacity
onPress={() => Linking.openSettings()}
className='mt-4 rounded-lg bg-purple-600 px-6 py-3'
>
<Text className='text-base font-semibold text-white'>
{t("companion_login.open_settings")}
</Text>
</TouchableOpacity>
)}
<Button
onPress={handleDone}
color='white'
className='mt-4'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "success") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.success_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.pairing_tv_connecting")}
</Text>
<Button
onPress={handleDone}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
);
}
if (screenState === "error") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='mb-3 text-center text-3xl font-bold text-white'>
{t("companion_login.error_title")}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{errorMessage}
</Text>
<View className='mt-4 flex-row gap-3'>
<Button
onPress={handleScanAgain}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleDone}
color='white'
textClassName='flex-1 text-center'
>
{t("companion_login.done")}
</Button>
</View>
</View>
</View>
);
}
if (screenState === "sending") {
return (
<View className='flex-1 bg-black'>
<View className='flex-1 items-center justify-center p-8'>
<Text className='text-xl text-white'>
{t("companion_login.authorizing")}
</Text>
</View>
</View>
);
}
if (screenState === "confirm") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 24,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-center text-2xl font-bold text-white'>
{t("companion_login.login_as", { username })}
</Text>
<Text className='mb-8 text-center text-base text-gray-400'>
{t("companion_login.on_server", {
server: serverUrl.replace(/^https?:\/\//, ""),
})}
</Text>
<View className='mb-6 items-center'>
<Text className='mb-1 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<Text className='mb-8 text-center text-4xl font-bold tracking-[6px] text-white'>
{pairingCode}
</Text>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
autoFocus
/>
</View>
<View className='mt-2'>
<Button
onPress={handleSendCredentials}
disabled={!password.trim()}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
<View className='mt-6 items-center'>
<TouchableOpacity onPress={handleUseDifferentUser} className='py-2'>
<Text className='text-base text-gray-400 underline'>
{t("companion_login.use_different_user")}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleScanAgain} className='py-2'>
<Text className='text-sm text-gray-500 underline'>
{t("companion_login.scan_again")}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
if (screenState === "form") {
return (
<KeyboardAvoidingView
className='flex-1 bg-black'
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
padding: 14,
}}
keyboardShouldPersistTaps='handled'
>
<Text className='mb-2 text-2xl font-bold text-white'>
{t("companion_login.pairing_enter_credentials")}
</Text>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.pairing_code_label")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-center text-2xl font-bold tracking-[6px] text-white'
value={pairingCode}
onChangeText={setPairingCode}
placeholder={t("companion_login.pairing_code_label")}
placeholderTextColor='#6B7280'
autoCapitalize='characters'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("companion_login.server")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={serverUrl}
onChangeText={setServerUrl}
placeholder={t("server.server_url_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
keyboardType='url'
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.username_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={username}
onChangeText={setUsername}
placeholder={t("login.username_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
returnKeyType='next'
/>
</View>
<View className='mb-5'>
<Text className='mb-2 text-sm text-gray-400'>
{t("login.password_placeholder")}
</Text>
<TextInput
className='rounded-lg border border-neutral-700 bg-neutral-900 p-3 text-base text-white'
value={password}
onChangeText={setPassword}
placeholder={t("login.password_placeholder")}
placeholderTextColor='#6B7280'
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
returnKeyType='done'
onSubmitEditing={handleSendCredentials}
/>
</View>
<View className='flex-row justify-center gap-3'>
<Button
onPress={handleScanAgain}
color='black'
className='w-40 border border-neutral-700 bg-neutral-800'
textClassName='flex-1 text-center'
>
{t("companion_login.scan_again")}
</Button>
<Button
onPress={handleSendCredentials}
disabled={
!serverUrl.trim() ||
!username.trim() ||
!password.trim() ||
!pairingCode.trim()
}
className='w-40'
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.authorize_button")}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const CameraView = ExpoCamera?.CameraView;
if (!CameraView) {
return (
<View className='flex-1 bg-black items-center justify-center p-8'>
<Button
onPress={handleEnterCodeManually}
color='purple'
textClassName='flex-1 text-center'
>
{t("companion_login.enter_code_manually")}
</Button>
</View>
);
}
return (
<View className='flex-1 bg-black items-center justify-center'>
{/* Camera full screen */}
<CameraView
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
{/* Dark overlay */}
<View className='absolute inset-0 bg-black/60' />
{/* Center scan area */}
<View className='items-center'>
<View className='h-[250px] w-[250px] rounded-2xl border-2 border-white/80' />
<Text className='mt-6 text-center text-base text-white'>
{t("companion_login.align_qr")}
</Text>
<TouchableOpacity
onPress={handleEnterCodeManually}
className='mt-4 px-5 py-2'
>
<Text className='text-sm text-gray-400 underline'>
{t("companion_login.enter_code_manually")}
</Text>
</TouchableOpacity>
</View>
</View>
);
};

View File

@@ -178,8 +178,6 @@ export const Favorites = () => {
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>

View File

@@ -36,14 +36,21 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import {
apiAtom,
cacheVersionAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { scaleSize } from "@/utils/scaleSize";
import { updateTVDiscovery } from "@/utils/tvDiscovery/sync";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const HORIZONTAL_PADDING = scaleSize(60);
const TOP_PADDING = scaleSize(100);
// Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = 24;
const SECTION_GAP = scaleSize(24);
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -52,7 +59,6 @@ type InfiniteScrollingCollectionListSection = {
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
priority?: 1 | 2;
parentId?: string;
};
@@ -67,6 +73,7 @@ export const Home = () => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const cacheVersion = useAtomValue(cacheVersionAtom);
const insets = useSafeAreaInsets();
const { settings } = useSettings();
const scrollRef = useRef<ScrollView>(null);
@@ -77,7 +84,7 @@ export const Home = () => {
retryCheck,
} = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showItemActions } = useTVItemActionModal();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
@@ -250,13 +257,25 @@ export const Home = () => {
deduped.push(item);
}
return deduped.slice(0, 8);
return deduped.slice(0, 15);
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
useEffect(() => {
updateTVDiscovery({
api,
sections: [
{
title: t("home.continue_and_next_up"),
items: heroItems,
},
],
});
}, [api, heroItems, t]);
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
@@ -381,7 +400,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
]
: [
@@ -401,7 +419,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
{
title: t("home.next_up"),
@@ -419,13 +436,12 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
];
const ss: Section[] = [
...firstSections,
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
...latestMediaViews,
...(!settings?.streamyStatsMovieRecommendations
? [
{
@@ -444,7 +460,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const,
pageSize: 10,
priority: 2 as const,
},
]
: []),
@@ -529,7 +544,6 @@ export const Home = () => {
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
priority: index < 2 ? 1 : 2,
});
});
return ss;
@@ -537,23 +551,21 @@ export const Home = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
const highPrioritySectionKeys = useMemo(() => {
return sections
.filter((s) => s.priority === 1)
.map((s) => s.queryKey.join("-"));
}, [sections]);
// Determine if hero should be shown (separate setting from backdrop)
// We need this early to calculate which sections will actually be rendered
const showHero = useMemo(() => {
return heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
}, [heroItems, settings.showTVHeroCarousel]);
const allHighPriorityLoaded = useMemo(() => {
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
}, [highPrioritySectionKeys, loadedSections]);
const markSectionLoaded = useCallback(
(queryKey: (string | undefined | null)[]) => {
const key = queryKey.join("-");
setLoadedSections((prev) => new Set(prev).add(key));
},
[],
);
// Get sections that will actually be rendered (accounting for hero slicing)
// When hero is shown, skip the first sections since hero already displays that content
// - If mergeNextUpAndContinueWatching: skip 1 section (combined Continue & Next Up)
// - Otherwise: skip 2 sections (separate Continue Watching + Next Up)
const renderedSections = useMemo(() => {
if (!showHero) return sections;
const sectionsToSkip = settings.mergeNextUpAndContinueWatching ? 1 : 2;
return sections.slice(sectionsToSkip);
}, [sections, showHero, settings.mergeNextUpAndContinueWatching]);
if (!isConnected || serverConnected !== true) {
let title = "";
@@ -661,12 +673,8 @@ export const Home = () => {
</View>
);
// Determine if hero should be shown (separate setting from backdrop)
const showHero =
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View key={cacheVersion} style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
{!showHero && settings.showHomeBackdrop && (
<View
@@ -737,28 +745,29 @@ export const Home = () => {
}}
>
{/* Hero Carousel - Apple TV+ style featured content */}
{showHero && (
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
{showHero && heroItems && (
<TVHeroCarousel
items={heroItems}
onItemFocus={handleItemFocus}
onItemLongPress={showItemActions}
/>
)}
<View
style={{
gap: SECTION_GAP,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingTop: showHero ? SECTION_GAP : 0,
}}
>
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
{sections.slice(showHero ? 1 : 0).map((section, index) => {
{renderedSections.map((section, index) => {
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
// Adjust index calculation to account for sliced array when hero is shown
const displayedSectionsLength =
sections.length - (showHero ? 1 : 0);
const displayedSectionsLength = renderedSections.length;
const streamystatsIndex =
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
@@ -774,7 +783,6 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
@@ -784,13 +792,11 @@ export const Home = () => {
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
@@ -798,7 +804,6 @@ export const Home = () => {
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
// First section only gets preferred focus if hero is not shown
const isFirstSection = index === 0 && !showHero;
return (
@@ -810,12 +815,6 @@ export const Home = () => {
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
parentId={section.parentId}

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -16,21 +16,18 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
import { scaleSize } from "@/utils/scaleSize";
const ITEM_GAP = 24;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
const _SCALE_PADDING = scaleSize(20);
interface Props extends ViewProps {
title?: string | null;
@@ -42,64 +39,13 @@ interface Props extends ViewProps {
pageSize?: number;
onPressSeeAll?: () => void;
enabled?: boolean;
onLoaded?: () => void;
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
parentId?: string;
}
type Typography = ReturnType<typeof useScaledTVTypography>;
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (
<>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
type PosterSizes = ReturnType<typeof useScaledTVPosterSizes>;
// TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{
@@ -109,10 +55,19 @@ const TVSeeAllCard: React.FC<{
onFocus?: () => void;
onBlur?: () => void;
typography: Typography;
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
posterSizes: PosterSizes;
}> = ({
onPress,
orientation,
disabled,
onFocus,
onBlur,
typography,
posterSizes,
}) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
@@ -127,7 +82,7 @@ const TVSeeAllCard: React.FC<{
style={{
width,
aspectRatio,
borderRadius: 24,
borderRadius: scaleSize(24),
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
@@ -137,9 +92,9 @@ const TVSeeAllCard: React.FC<{
>
<Ionicons
name='arrow-forward'
size={32}
size={scaleSize(32)}
color='white'
style={{ marginBottom: 8 }}
style={{ marginBottom: scaleSize(8) }}
/>
<Text
style={{
@@ -165,71 +120,49 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hideIfEmpty = false,
pageSize = 10,
enabled = true,
onLoaded,
isFirstSection = false,
onItemFocus,
parentId,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
// Track focus within section for item focus/blur callbacks
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [_focusedCount, setFocusedCount] = useState(0);
// Pass through focus callbacks without tracking internal state
const handleItemFocus = useCallback(
(item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item);
},
[onItemFocus],
);
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Focus handler for See All card (doesn't need item parameter)
const handleSeeAllFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isSuccess,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
useEffect(() => {
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
hasCalledOnLoaded.current = true;
onLoaded();
}
}, [isSuccess, onLoaded]);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation();
@@ -250,7 +183,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [data]);
const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
@@ -278,79 +211,21 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
} as any);
}, [router, parentId]);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return (
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation={orientation}
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
width={itemWidth}
/>
</View>
);
},
@@ -359,9 +234,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection,
itemWidth,
handleItemPress,
showItemActions,
handleItemFocus,
handleItemBlur,
typography,
ITEM_GAP,
],
);
@@ -376,8 +251,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: SCALE_PADDING,
marginBottom: scaleSize(20),
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
@@ -389,7 +264,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{t("home.no_items")}
@@ -401,8 +276,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.gaps.small,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
@@ -412,12 +288,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
@@ -453,18 +330,29 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in TVHeroCarousel, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
ListFooterComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
width: sizes.padding.horizontal,
}}
>
{isFetchingNextPage && (
@@ -473,7 +361,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
marginLeft: itemWidth / 2,
marginRight: ITEM_GAP,
justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315,
height:
orientation === "horizontal"
? scaleSize(191)
: scaleSize(315),
}}
>
<ActivityIndicator size='small' color='white' />
@@ -484,9 +375,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPress={handleSeeAllPress}
orientation={orientation}
disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography}
posterSizes={posterSizes}
/>
)}
</View>

View File

@@ -11,47 +11,19 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
const SCALE_PADDING = scaleSize(20);
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
@@ -66,10 +38,14 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -129,30 +105,35 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: posterSizes.poster + ITEM_GAP,
offset: (posterSizes.poster + ITEM_GAP) * index,
index,
}),
[],
[posterSizes.poster, ITEM_GAP],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
<View style={{ marginRight: ITEM_GAP }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
width={posterSizes.poster}
/>
</View>
);
},
[handleItemPress, onItemFocus, typography],
[
ITEM_GAP,
posterSizes.poster,
handleItemPress,
showItemActions,
onItemFocus,
],
);
if (!isLoading && (!items || items.length === 0)) return null;
@@ -165,7 +146,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
@@ -177,21 +158,43 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
@@ -208,9 +211,13 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
@@ -226,9 +233,13 @@ interface StreamystatsPromotedWatchlistsProps extends ViewProps {
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const typography = useScaledTVTypography();
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
@@ -299,33 +310,55 @@ export const StreamystatsPromotedWatchlists: React.FC<
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: 16,
width: 128,
height: scaleSize(16),
width: scaleSize(128),
backgroundColor: "#262626",
borderRadius: 4,
marginLeft: SCALE_PADDING,
marginBottom: 16,
borderRadius: scaleSize(4),
marginLeft: sizes.padding.horizontal,
marginBottom: scaleSize(20),
}}
/>
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: posterSizes.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: posterSizes.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>

View File

@@ -11,23 +11,17 @@ import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
@@ -36,31 +30,6 @@ interface Props extends ViewProps {
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
@@ -70,10 +39,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
...props
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -190,30 +161,29 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
length: sizes.posters.poster + sizes.gaps.item,
offset: (sizes.posters.poster + sizes.gaps.item) * index,
index,
}),
[],
[sizes],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
<View style={{ marginRight: sizes.gaps.item }}>
<TVPosterCard
item={item}
orientation='vertical'
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} typography={typography} />
width={sizes.posters.poster}
/>
</View>
);
},
[handleItemPress, onItemFocus, typography],
[sizes, handleItemPress, showItemActions, onItemFocus],
);
if (!streamyStatsEnabled) return null;
@@ -228,7 +198,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
@@ -239,22 +209,44 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
paddingVertical: sizes.padding.scale,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View key={i} style={{ width: sizes.posters.poster }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
width: sizes.posters.poster,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
borderRadius: scaleSize(24),
}}
/>
<View
style={{
marginTop: scaleSize(12),
paddingHorizontal: scaleSize(4),
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: typography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
@@ -271,9 +263,13 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale,
}}
/>
)}

View File

@@ -23,6 +23,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -32,28 +33,28 @@ import {
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { scaleSize } from "@/utils/scaleSize";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
const CARD_WIDTH = 280;
const CARD_GAP = 24;
const CARD_PADDING = 60;
interface TVHeroCarouselProps {
items: BaseItemDto[];
onItemFocus?: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
}
interface HeroCardProps {
item: BaseItemDto;
isFirst: boolean;
sizes: ScaledTVSizes;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, onFocus, onPress }) => {
({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -84,8 +85,6 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return null;
}, [api, item]);
const progress = item.UserData?.PlayedPercentage || 0;
const animateTo = useCallback(
(value: number) =>
Animated.timing(scale, {
@@ -99,9 +98,9 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(1.1);
animateTo(sizes.animation.focusScale);
onFocus(item);
}, [animateTo, onFocus, item]);
}, [animateTo, onFocus, item, sizes.animation.focusScale]);
const handleBlur = useCallback(() => {
setFocused(false);
@@ -112,25 +111,31 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
onPress(item);
}, [onPress, item]);
const handleLongPress = useCallback(() => {
onLongPress?.(item);
}, [onLongPress, item]);
// Use glass poster for tvOS 26+
if (useGlass) {
if (useGlass && posterUrl) {
const progress = item.UserData?.PlayedPercentage || 0;
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: CARD_GAP }}
style={{ marginRight: sizes.gaps.item }}
>
<GlassPosterView
imageUrl={posterUrl}
aspectRatio={16 / 9}
cornerRadius={16}
cornerRadius={scaleSize(24)}
progress={progress}
showWatchedIndicator={false}
isFocused={focused}
width={CARD_WIDTH}
style={{ width: CARD_WIDTH }}
width={sizes.posters.episode}
style={{ width: sizes.posters.episode }}
/>
</Pressable>
);
@@ -140,22 +145,25 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
style={{ marginRight: CARD_GAP }}
style={{ marginRight: sizes.gaps.item }}
>
<Animated.View
style={{
width: CARD_WIDTH,
width: sizes.posters.episode,
aspectRatio: 16 / 9,
borderRadius: 16,
borderRadius: scaleSize(24),
overflow: "hidden",
transform: [{ scale }],
borderWidth: scaleSize(2),
borderColor: focused ? "#FFFFFF" : "transparent",
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
shadowRadius: focused ? scaleSize(20) : 0,
}}
>
{posterUrl ? (
@@ -176,7 +184,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
>
<Ionicons
name='film-outline'
size={48}
size={scaleSize(48)}
color='rgba(255,255,255,0.3)'
/>
</View>
@@ -194,8 +202,10 @@ const BACKDROP_DEBOUNCE_MS = 300;
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
items,
onItemFocus,
onItemLongPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -354,11 +364,13 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<HeroCard
item={item}
isFirst={index === 0}
sizes={sizes}
onFocus={handleCardFocus}
onPress={handleCardPress}
onLongPress={onItemLongPress}
/>
),
[handleCardFocus, handleCardPress],
[handleCardFocus, handleCardPress, onItemLongPress, sizes],
);
// Memoize keyExtractor
@@ -366,8 +378,18 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
if (items.length === 0) return null;
// Extra top padding for tvOS to clear the menu bar
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
return (
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
<View
style={{
height: heroHeight + insets.top + tvosTopPadding,
width: "100%",
paddingTop: insets.top + tvosTopPadding,
}}
>
{/* Backdrop layers with crossfade */}
<View
style={{
@@ -452,13 +474,17 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
/>
</View>
{/* Content overlay */}
{/* Content overlay - text elements with padding */}
<View
style={{
position: "absolute",
left: insets.left + CARD_PADDING,
right: insets.right + CARD_PADDING,
bottom: 40,
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
bottom:
scaleSize(40) +
sizes.posters.episode * (9 / 16) +
sizes.gaps.small * 2 +
scaleSize(20),
}}
>
{/* Logo or Title */}
@@ -466,9 +492,9 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
<Image
source={{ uri: logoUrl }}
style={{
height: 100,
height: scaleSize(100),
width: SCREEN_WIDTH * 0.35,
marginBottom: 16,
marginBottom: scaleSize(16),
}}
contentFit='contain'
contentPosition='left'
@@ -479,7 +505,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -493,7 +519,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.9)",
marginBottom: 12,
marginBottom: scaleSize(12),
}}
numberOfLines={1}
>
@@ -507,7 +533,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.8)",
marginBottom: 16,
marginBottom: scaleSize(16),
maxWidth: SCREEN_WIDTH * 0.5,
lineHeight: typography.body * 1.4,
}}
@@ -522,8 +548,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 16,
marginBottom: 20,
gap: scaleSize(16),
}}
>
{year && (
@@ -549,10 +574,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
{activeItem?.OfficialRating && (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 1,
paddingHorizontal: scaleSize(8),
paddingVertical: scaleSize(2),
borderRadius: scaleSize(4),
borderWidth: scaleSize(1),
borderColor: "rgba(255,255,255,0.5)",
}}
>
@@ -571,15 +596,15 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
style={{
flexDirection: "row",
alignItems: "center",
gap: 6,
gap: scaleSize(6),
}}
>
<View
style={{
width: 60,
height: 4,
width: scaleSize(60),
height: scaleSize(4),
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: 2,
borderRadius: scaleSize(2),
overflow: "hidden",
}}
>
@@ -603,15 +628,38 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
</View>
)}
</View>
</View>
{/* Thumbnail carousel */}
{/* Thumbnail carousel - edge-to-edge */}
<View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: scaleSize(40),
}}
>
<FlatList
horizontal
data={heroItems}
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 12 }}
contentContainerStyle={{
paddingVertical: sizes.gaps.small,
paddingLeft: sizes.padding.horizontal,
paddingRight: sizes.padding.horizontal,
}}
// Below is a work around with the contentInset, same in infiniteScrollingCollectionList, if okay on apple remove
// ListHeaderComponent={
// <View style={{ width: sizes.padding.horizontal }} />
// }
// contentInset={{
// left: sizes.padding.horizontal,
// right: sizes.padding.horizontal,
// }}
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
// contentContainerStyle={{ paddingVertical: sizes.gaps.small }}
renderItem={renderHeroCard}
removeClippedSubviews={false}
initialNumToRender={8}

View File

@@ -103,6 +103,8 @@ const TVLibraryRow: React.FC<{
return t("library.item_types.series");
if (library.CollectionType === "boxsets")
return t("library.item_types.boxsets");
if (library.CollectionType === "playlists")
return t("library.item_types.playlists");
if (library.CollectionType === "music")
return t("library.item_types.items");
return t("library.item_types.items");
@@ -258,8 +260,7 @@ export const TVLibraries: React.FC = () => {
userViews
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books")
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "playlists") || [],
.filter((l) => l.CollectionType !== "music") || [],
[userViews, settings?.hiddenLibraries],
);
@@ -273,6 +274,10 @@ export const TVLibraries: React.FC = () => {
if (library.CollectionType === "movies") itemType = "Movie";
else if (library.CollectionType === "tvshows") itemType = "Series";
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
else if (library.CollectionType === "playlists")
itemType = "Playlist";
const isPlaylistsLib = library.CollectionType === "playlists";
// Fetch count
const countResponse = await getItemsApi(api!).getItems({
@@ -281,6 +286,7 @@ export const TVLibraries: React.FC = () => {
recursive: true,
limit: 0,
includeItemTypes: itemType ? [itemType as any] : undefined,
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
// Fetch preview items with backdrops
@@ -292,6 +298,7 @@ export const TVLibraries: React.FC = () => {
sortBy: ["Random"],
includeItemTypes: itemType ? [itemType as any] : undefined,
imageTypes: ["Backdrop"],
...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}),
});
return {
@@ -309,6 +316,10 @@ export const TVLibraries: React.FC = () => {
const handleLibraryPress = useCallback(
(library: BaseItemDto) => {
if (library.CollectionType === "livetv") {
router.push("/(auth)/(tabs)/(libraries)/livetv/programs");
return;
}
if (library.CollectionType === "music") {
router.push({
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,

View File

@@ -0,0 +1,183 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
interface TVChannelCardProps {
channel: BaseItemDto;
api: Api | null;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
const CARD_WIDTH = 200;
const CARD_HEIGHT = 160;
export const TVChannelCard: React.FC<TVChannelCardProps> = ({
channel,
api,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const imageUrl = getPrimaryImageUrl({
api,
item: channel,
quality: 80,
width: 200,
});
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusable={!disabled}
style={styles.pressable}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
animatedStyle,
focused && styles.focusedShadow,
]}
>
{/* Channel logo or number */}
<View style={styles.logoContainer}>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={styles.logo}
resizeMode='contain'
/>
) : (
<View
style={[
styles.numberFallback,
{
backgroundColor: focused
? "#E5E5E5"
: "rgba(255, 255, 255, 0.15)",
},
]}
>
<Text
style={[
styles.numberText,
{
fontSize: typography.title,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.ChannelNumber || "?"}
</Text>
</View>
)}
</View>
{/* Channel name */}
<Text
numberOfLines={2}
style={[
styles.channelName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{channel.Name}
</Text>
{/* Channel number (if name is shown) */}
{channel.ChannelNumber && (
<Text
numberOfLines={1}
style={[
styles.channelNumber,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
Ch. {channel.ChannelNumber}
</Text>
)}
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
},
container: {
flex: 1,
borderRadius: 12,
borderWidth: 1,
padding: 12,
alignItems: "center",
justifyContent: "center",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
logoContainer: {
width: 80,
height: 60,
marginBottom: 8,
justifyContent: "center",
alignItems: "center",
},
logo: {
width: "100%",
height: "100%",
},
numberFallback: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
},
numberText: {
fontWeight: "bold",
},
channelName: {
fontWeight: "600",
textAlign: "center",
marginBottom: 4,
},
channelNumber: {
fontWeight: "400",
},
});
export { CARD_WIDTH, CARD_HEIGHT };

View File

@@ -0,0 +1,136 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { TVChannelCard } from "./TVChannelCard";
const HORIZONTAL_PADDING = 60;
const GRID_GAP = 16;
export const TVChannelsGrid: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Fetch all channels
const { data: channelsData, isLoading } = useQuery({
queryKey: ["livetv", "channels-grid", "all"],
queryFn: async () => {
if (!api || !user?.Id) return null;
const res = await getLiveTvApi(api).getLiveTvChannels({
enableFavoriteSorting: true,
userId: user.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
enabled: !!api && !!user?.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const channels = channelsData?.Items ?? [];
const handleChannelPress = useCallback(
(channelId: string | undefined) => {
if (channelId) {
// Navigate directly to the player to start the channel
const queryParams = new URLSearchParams({
itemId: channelId,
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
}
},
[router],
);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (channels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_channels")}
</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
{
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
paddingBottom: insets.bottom + 60,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.grid}>
{channels.map((channel, index) => (
<TVChannelCard
key={channel.Id ?? index}
channel={channel}
api={api}
onPress={() => handleChannelPress(channel.Id)}
// No hasTVPreferredFocus - tab buttons handle initial focus
/>
))}
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingTop: 24,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: GRID_GAP,
overflow: "visible",
paddingVertical: 10, // Extra padding for focus scale animation
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -0,0 +1,146 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo } from "react";
import { StyleSheet, View } from "react-native";
import { TVGuideProgramCell } from "./TVGuideProgramCell";
interface TVGuideChannelRowProps {
programs: BaseItemDto[];
baseTime: Date;
pixelsPerHour: number;
minProgramWidth: number;
hoursToShow: number;
onProgramPress: (program: BaseItemDto) => void;
disabled?: boolean;
firstProgramRefSetter?: (ref: View | null) => void;
}
export const TVGuideChannelRow: React.FC<TVGuideChannelRowProps> = ({
programs,
baseTime,
pixelsPerHour,
minProgramWidth,
hoursToShow,
onProgramPress,
disabled = false,
firstProgramRefSetter,
}) => {
const isCurrentlyAiring = (program: BaseItemDto): boolean => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
const getTimeOffset = (startDate: string): number => {
const start = new Date(startDate);
const diffMinutes = (start.getTime() - baseTime.getTime()) / 60000;
return Math.max(0, (diffMinutes / 60) * pixelsPerHour);
};
// Filter programs for this channel and within the time window
const filteredPrograms = useMemo(() => {
const endTime = new Date(baseTime.getTime() + hoursToShow * 60 * 60 * 1000);
return programs
.filter((p) => {
if (!p.StartDate || !p.EndDate) return false;
const start = new Date(p.StartDate);
const end = new Date(p.EndDate);
// Program overlaps with our time window
return end > baseTime && start < endTime;
})
.sort((a, b) => {
const dateA = new Date(a.StartDate || 0);
const dateB = new Date(b.StartDate || 0);
return dateA.getTime() - dateB.getTime();
});
}, [programs, baseTime, hoursToShow]);
// Calculate program cells with positions (absolute positioning)
const programCells = useMemo(() => {
return filteredPrograms.map((program) => {
if (!program.StartDate || !program.EndDate) {
return { program, width: minProgramWidth, left: 0 };
}
// Clamp the start time to baseTime if program started earlier
const programStart = new Date(program.StartDate);
const effectiveStart = programStart < baseTime ? baseTime : programStart;
// Clamp the end time to the window end
const windowEnd = new Date(
baseTime.getTime() + hoursToShow * 60 * 60 * 1000,
);
const programEnd = new Date(program.EndDate);
const effectiveEnd = programEnd > windowEnd ? windowEnd : programEnd;
const durationMinutes =
(effectiveEnd.getTime() - effectiveStart.getTime()) / 60000;
const width = Math.max(
(durationMinutes / 60) * pixelsPerHour - 4,
minProgramWidth,
); // -4 for gap
const left = getTimeOffset(effectiveStart.toISOString());
return {
program,
width,
left,
};
});
}, [filteredPrograms, baseTime, pixelsPerHour, minProgramWidth, hoursToShow]);
const totalWidth = hoursToShow * pixelsPerHour;
return (
<View style={[styles.container, { width: totalWidth }]}>
{programCells.map(({ program, width, left }, index) => (
<View
key={program.Id || index}
style={[styles.programCellWrapper, { left, width }]}
>
<TVGuideProgramCell
program={program}
width={width}
isCurrentlyAiring={isCurrentlyAiring(program)}
onPress={() => onProgramPress(program)}
disabled={disabled}
refSetter={index === 0 ? firstProgramRefSetter : undefined}
/>
</View>
))}
{/* Empty state */}
{programCells.length === 0 && (
<View style={[styles.noPrograms, { width: totalWidth - 8 }]}>
{/* Empty row indicator */}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
height: 80,
position: "relative",
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
backgroundColor: "rgba(20, 20, 20, 1)",
},
programCellWrapper: {
position: "absolute",
top: 4,
bottom: 4,
},
noPrograms: {
position: "absolute",
left: 4,
top: 4,
bottom: 4,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
},
});

View File

@@ -0,0 +1,154 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuidePageNavigationProps {
currentPage: number;
totalPages: number;
onPrevious: () => void;
onNext: () => void;
disabled?: boolean;
prevButtonRefSetter?: (ref: View | null) => void;
}
interface NavButtonProps {
onPress: () => void;
icon: keyof typeof Ionicons.glyphMap;
label: string;
isDisabled: boolean;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
const NavButton: React.FC<NavButtonProps> = ({
onPress,
icon,
label,
isDisabled,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
});
const visuallyDisabled = isDisabled || disabled;
const handlePress = () => {
if (!visuallyDisabled) {
onPress();
}
};
return (
<Pressable
ref={refSetter}
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
focusable={!disabled}
>
<Animated.View
style={[
styles.navButton,
animatedStyle,
{
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
opacity: visuallyDisabled ? 0.3 : 1,
},
]}
>
<Ionicons
name={icon}
size={20}
color={focused ? "#000000" : "#FFFFFF"}
/>
<Text
style={[
styles.navButtonText,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVGuidePageNavigation: React.FC<TVGuidePageNavigationProps> = ({
currentPage,
totalPages,
onPrevious,
onNext,
disabled = false,
prevButtonRefSetter,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
return (
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<NavButton
onPress={onPrevious}
icon='chevron-back'
label={t("live_tv.previous")}
isDisabled={currentPage <= 1}
disabled={disabled}
refSetter={prevButtonRefSetter}
/>
<NavButton
onPress={onNext}
icon='chevron-forward'
label={t("live_tv.next")}
isDisabled={currentPage >= totalPages}
disabled={disabled}
/>
</View>
<Text style={[styles.pageText, { fontSize: typography.callout }]}>
{t("live_tv.page_of", { current: currentPage, total: totalPages })}
</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 16,
},
buttonsContainer: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
navButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
navButtonText: {
fontWeight: "600",
},
pageText: {
color: "rgba(255, 255, 255, 0.6)",
},
});

View File

@@ -0,0 +1,148 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideProgramCellProps {
program: BaseItemDto;
width: number;
isCurrentlyAiring: boolean;
onPress: () => void;
disabled?: boolean;
refSetter?: (ref: View | null) => void;
}
export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
program,
width,
isCurrentlyAiring,
onPress,
disabled = false,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
scaleAmount: 1,
duration: 120,
});
const formatTime = (date: string | null | undefined) => {
if (!date) return "";
const d = new Date(date);
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
style={{ width }}
>
<Animated.View
style={[
styles.container,
{
backgroundColor: focused
? "#FFFFFF"
: isCurrentlyAiring
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.08)",
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
},
focused && styles.focusedShadow,
]}
>
{/* LIVE badge */}
{isCurrentlyAiring && (
<View style={styles.liveBadge}>
<Text
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
>
LIVE
</Text>
</View>
)}
{/* Program name */}
<Text
numberOfLines={2}
style={[
styles.programName,
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
>
{program.Name}
</Text>
{/* Time range */}
<Text
numberOfLines={1}
style={[
styles.timeText,
{
fontSize: typography.callout,
color: focused
? "rgba(0, 0, 0, 0.6)"
: "rgba(255, 255, 255, 0.5)",
},
]}
>
{formatTime(program.StartDate)} - {formatTime(program.EndDate)}
</Text>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
height: 70,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
justifyContent: "center",
overflow: "hidden",
},
focusedShadow: {
shadowColor: "#FFFFFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
liveBadge: {
position: "absolute",
top: 6,
right: 6,
backgroundColor: "#EF4444",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
zIndex: 10,
elevation: 10,
},
liveBadgeText: {
color: "#FFFFFF",
fontWeight: "bold",
},
programName: {
fontWeight: "600",
marginBottom: 4,
},
timeText: {
fontWeight: "400",
},
});

View File

@@ -0,0 +1,64 @@
import { BlurView } from "expo-blur";
import React from "react";
import { StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVGuideTimeHeaderProps {
baseTime: Date;
hoursToShow: number;
pixelsPerHour: number;
}
export const TVGuideTimeHeader: React.FC<TVGuideTimeHeaderProps> = ({
baseTime,
hoursToShow,
pixelsPerHour,
}) => {
const typography = useScaledTVTypography();
const hours: Date[] = [];
for (let i = 0; i < hoursToShow; i++) {
const hour = new Date(baseTime);
hour.setMinutes(0, 0, 0);
hour.setHours(baseTime.getHours() + i);
hours.push(hour);
}
const formatHour = (date: Date) => {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<BlurView intensity={40} tint='dark' style={styles.container}>
{hours.map((hour, index) => (
<View key={index} style={[styles.hourCell, { width: pixelsPerHour }]}>
<Text style={[styles.hourText, { fontSize: typography.callout }]}>
{formatHour(hour)}
</Text>
</View>
))}
</BlurView>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
height: 44,
},
hourCell: {
justifyContent: "center",
paddingLeft: 12,
borderLeftWidth: 1,
borderLeftColor: "rgba(255, 255, 255, 0.1)",
},
hourText: {
color: "rgba(255, 255, 255, 0.6)",
fontWeight: "500",
},
});

View File

@@ -0,0 +1,433 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { TVGuideChannelRow } from "./TVGuideChannelRow";
import { TVGuidePageNavigation } from "./TVGuidePageNavigation";
import { TVGuideTimeHeader } from "./TVGuideTimeHeader";
// Design constants
const CHANNEL_COLUMN_WIDTH = 240;
const PIXELS_PER_HOUR = 250;
const ROW_HEIGHT = 80;
const TIME_HEADER_HEIGHT = 44;
const CHANNELS_PER_PAGE = 20;
const MIN_PROGRAM_WIDTH = 80;
const HORIZONTAL_PADDING = 60;
// Channel label component
const ChannelLabel: React.FC<{
channel: BaseItemDto;
typography: ReturnType<typeof useScaledTVTypography>;
}> = ({ channel, typography }) => (
<View style={styles.channelLabel}>
<Text
numberOfLines={1}
style={[styles.channelNumber, { fontSize: typography.callout }]}
>
{channel.ChannelNumber}
</Text>
<Text
numberOfLines={1}
style={[styles.channelName, { fontSize: typography.callout }]}
>
{channel.Name}
</Text>
</View>
);
export const TVLiveTVGuide: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [currentPage, setCurrentPage] = useState(1);
// Scroll refs for synchronization
const channelListRef = useRef<ScrollView>(null);
const mainVerticalRef = useRef<ScrollView>(null);
// Focus guide refs for bidirectional navigation
const [firstProgramRef, setFirstProgramRef] = useState<View | null>(null);
const [prevButtonRef, setPrevButtonRef] = useState<View | null>(null);
// Base time - start of current hour, end time - end of day
const [{ baseTime, endOfDay, hoursToShow }] = useState(() => {
const now = new Date();
now.setMinutes(0, 0, 0);
const endOfDayTime = new Date(now);
endOfDayTime.setHours(23, 59, 59, 999);
const hoursUntilEndOfDay = Math.ceil(
(endOfDayTime.getTime() - now.getTime()) / (60 * 60 * 1000),
);
return {
baseTime: now,
endOfDay: endOfDayTime,
hoursToShow: Math.max(hoursUntilEndOfDay, 1), // At least 1 hour
};
});
// Current time indicator position (relative to program grid start)
const [currentTimeOffset, setCurrentTimeOffset] = useState(0);
// Update current time indicator every minute
useEffect(() => {
const updateCurrentTime = () => {
const now = new Date();
const diffMinutes = (now.getTime() - baseTime.getTime()) / 60000;
const offset = (diffMinutes / 60) * PIXELS_PER_HOUR;
setCurrentTimeOffset(offset);
};
updateCurrentTime();
const interval = setInterval(updateCurrentTime, 60000);
return () => clearInterval(interval);
}, [baseTime]);
// Sync vertical scroll between channel list and main grid
const handleVerticalScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
channelListRef.current?.scrollTo({ y: offsetY, animated: false });
},
[],
);
// Fetch channels
const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
queryKey: ["livetv", "tv-guide", "channels"],
queryFn: async () => {
if (!api || !user?.Id) return null;
const res = await getLiveTvApi(api).getLiveTvChannels({
enableFavoriteSorting: true,
userId: user.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
enabled: !!api && !!user?.Id,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const totalChannels = channelsData?.TotalRecordCount ?? 0;
const totalPages = Math.ceil(totalChannels / CHANNELS_PER_PAGE);
const allChannels = channelsData?.Items ?? [];
// Get channels for current page
const paginatedChannels = useMemo(() => {
const startIndex = (currentPage - 1) * CHANNELS_PER_PAGE;
return allChannels.slice(startIndex, startIndex + CHANNELS_PER_PAGE);
}, [allChannels, currentPage]);
const channelIds = useMemo(
() => paginatedChannels.map((c) => c.Id).filter(Boolean) as string[],
[paginatedChannels],
);
// Fetch programs for visible channels
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
queryKey: [
"livetv",
"tv-guide",
"programs",
channelIds,
baseTime.toISOString(),
endOfDay.toISOString(),
],
queryFn: async () => {
if (!api || channelIds.length === 0) return null;
const res = await getLiveTvApi(api).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: baseTime.toISOString(),
ChannelIds: channelIds,
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: channelIds.length > 0,
staleTime: 2 * 60 * 1000, // 2 minutes
});
const programs = programsData?.Items ?? [];
// Group programs by channel
const programsByChannel = useMemo(() => {
const grouped: Record<string, BaseItemDto[]> = {};
for (const program of programs) {
const channelId = program.ChannelId;
if (channelId) {
if (!grouped[channelId]) {
grouped[channelId] = [];
}
grouped[channelId].push(program);
}
}
return grouped;
}, [programs]);
const handleProgramPress = useCallback(
(program: BaseItemDto) => {
// Navigate to play the program/channel
const queryParams = new URLSearchParams({
itemId: program.Id ?? "",
audioIndex: "",
subtitleIndex: "",
mediaSourceId: "",
bitrateValue: "",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
},
[router],
);
const handlePreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage((p) => p - 1);
}
}, [currentPage]);
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage((p) => p + 1);
}
}, [currentPage, totalPages]);
const isLoading = isLoadingChannels;
const totalWidth = hoursToShow * PIXELS_PER_HOUR;
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#FFFFFF' />
</View>
);
}
if (paginatedChannels.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
{t("live_tv.no_programs")}
</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Page Navigation */}
{totalPages > 1 && (
<View style={{ paddingHorizontal: insets.left + HORIZONTAL_PADDING }}>
<TVGuidePageNavigation
currentPage={currentPage}
totalPages={totalPages}
onPrevious={handlePreviousPage}
onNext={handleNextPage}
prevButtonRefSetter={setPrevButtonRef}
/>
</View>
)}
{/* Bidirectional focus guides */}
{firstProgramRef && (
<TVFocusGuideView
destinations={[firstProgramRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{prevButtonRef && (
<TVFocusGuideView
destinations={[prevButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Main grid container */}
<View style={styles.gridWrapper}>
{/* Fixed channel column */}
<View
style={[
styles.channelColumn,
{
width: CHANNEL_COLUMN_WIDTH,
marginLeft: insets.left + HORIZONTAL_PADDING,
},
]}
>
{/* Spacer for time header */}
<View style={{ height: TIME_HEADER_HEIGHT }} />
{/* Channel labels - synced with main scroll */}
<ScrollView
ref={channelListRef}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => (
<ChannelLabel
key={channel.Id ?? index}
channel={channel}
typography={typography}
/>
))}
</ScrollView>
</View>
{/* Scrollable programs area */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.horizontalScroll}
contentContainerStyle={{
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ width: totalWidth, position: "relative" }}>
{/* Time header */}
<TVGuideTimeHeader
baseTime={baseTime}
hoursToShow={hoursToShow}
pixelsPerHour={PIXELS_PER_HOUR}
/>
{/* Programs grid - vertical scroll */}
<ScrollView
ref={mainVerticalRef}
onScroll={handleVerticalScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 60 }}
>
{paginatedChannels.map((channel, index) => {
const channelPrograms = channel.Id
? (programsByChannel[channel.Id] ?? [])
: [];
return (
<TVGuideChannelRow
key={channel.Id ?? index}
programs={channelPrograms}
baseTime={baseTime}
pixelsPerHour={PIXELS_PER_HOUR}
minProgramWidth={MIN_PROGRAM_WIDTH}
hoursToShow={hoursToShow}
onProgramPress={handleProgramPress}
firstProgramRefSetter={
index === 0 ? setFirstProgramRef : undefined
}
/>
);
})}
</ScrollView>
{/* Current time indicator */}
{currentTimeOffset > 0 && currentTimeOffset < totalWidth && (
<View
style={[
styles.currentTimeIndicator,
{
left: currentTimeOffset,
top: 0,
height:
TIME_HEADER_HEIGHT +
paginatedChannels.length * ROW_HEIGHT,
},
]}
/>
)}
</View>
</ScrollView>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyText: {
color: "rgba(255, 255, 255, 0.6)",
},
gridWrapper: {
flex: 1,
flexDirection: "row",
},
channelColumn: {
backgroundColor: "rgba(40, 40, 40, 1)",
borderRightWidth: 1,
borderRightColor: "rgba(255, 255, 255, 0.2)",
},
channelLabel: {
height: ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
},
channelNumber: {
color: "rgba(255, 255, 255, 0.5)",
fontWeight: "400",
marginBottom: 2,
},
channelName: {
color: "#FFFFFF",
fontWeight: "600",
},
horizontalScroll: {
flex: 1,
},
currentTimeIndicator: {
position: "absolute",
width: 2,
backgroundColor: "#EF4444",
zIndex: 10,
pointerEvents: "none",
},
});

View File

@@ -0,0 +1,265 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
import { TVTabButton } from "@/components/tv/TVTabButton";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 24;
type TabId =
| "programs"
| "guide"
| "channels"
| "recordings"
| "schedule"
| "series";
interface Tab {
id: TabId;
labelKey: string;
}
const TABS: Tab[] = [
{ id: "programs", labelKey: "live_tv.tabs.programs" },
{ id: "guide", labelKey: "live_tv.tabs.guide" },
{ id: "channels", labelKey: "live_tv.tabs.channels" },
{ id: "recordings", labelKey: "live_tv.tabs.recordings" },
{ id: "schedule", labelKey: "live_tv.tabs.schedule" },
{ id: "series", labelKey: "live_tv.tabs.series" },
];
export const TVLiveTVPage: React.FC = () => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [activeTab, setActiveTab] = useState<TabId>("programs");
// Section configurations for Programs tab
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
return [
{
title: t("live_tv.on_now"),
queryKey: ["livetv", "tv", "onNow"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getRecommendedPrograms({
userId: user.Id,
isAiring: true,
limit: 24,
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.shows"),
queryKey: ["livetv", "tv", "shows"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isMovie: false,
isSeries: true,
isSports: false,
isNews: false,
isKids: false,
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.movies"),
queryKey: ["livetv", "tv", "movies"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isMovie: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.sports"),
queryKey: ["livetv", "tv", "sports"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isSports: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.for_kids"),
queryKey: ["livetv", "tv", "kids"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isKids: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
{
title: t("live_tv.news"),
queryKey: ["livetv", "tv", "news"],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) => {
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user.Id,
hasAired: false,
limit: 24,
isNews: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
const items = res.data.Items || [];
return items.slice(pageParam, pageParam + 10);
},
},
];
}, [api, user?.Id, t]);
const handleTabSelect = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
const renderProgramsContent = () => (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: 24,
paddingBottom: insets.bottom + 60,
}}
>
<View
style={{
gap: SECTION_GAP,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
}}
>
{sections.map((section) => (
<InfiniteScrollingCollectionList
key={section.queryKey.join("-")}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation='horizontal'
hideIfEmpty
pageSize={10}
enabled={true}
isFirstSection={false}
/>
))}
</View>
</ScrollView>
);
const renderTabContent = () => {
if (activeTab === "programs") {
return renderProgramsContent();
}
if (activeTab === "guide") {
return <TVLiveTVGuide />;
}
if (activeTab === "channels") {
return <TVChannelsGrid />;
}
// Placeholder for other tabs
const tab = TABS.find((t) => t.id === activeTab);
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
};
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Header with Title and Tabs */}
<View
style={{
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingBottom: 24,
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 24,
}}
>
Live TV
</Text>
{/* Tab Bar */}
<View
style={{
flexDirection: "row",
gap: 8,
}}
>
{TABS.map((tab) => (
<TVTabButton
key={tab.id}
label={t(tab.labelKey)}
active={activeTab === tab.id}
onSelect={() => handleTabSelect(tab.id)}
hasTVPreferredFocus={activeTab === tab.id}
switchOnFocus={true}
/>
))}
</View>
</View>
{/* Tab Content */}
<View style={{ flex: 1 }}>{renderTabContent()}</View>
</View>
);
};

View File

@@ -0,0 +1,46 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVLiveTVPlaceholderProps {
tabName: string;
}
export const TVLiveTVPlaceholder: React.FC<TVLiveTVPlaceholderProps> = ({
tabName,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 60,
}}
>
<Text
style={{
fontSize: typography.title,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 12,
}}
>
{tabName}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255, 255, 255, 0.6)",
}}
>
{t("live_tv.coming_soon")}
</Text>
</View>
);
};

View File

@@ -72,22 +72,24 @@ export const Login: React.FC = () => {
password: string;
} | null>(null);
// Handle URL params for server connection
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
}, [_apiUrl]);
// Handle auto-login when api is ready and credentials are provided via URL params
useEffect(() => {
if (api?.basePath && _apiUrl && _username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, [api?.basePath, _apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({
@@ -380,18 +382,16 @@ export const Login: React.FC = () => {
</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'>
<View className='flex flex-col flex-1 w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
alignSelf: "center",
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
@@ -429,6 +429,8 @@ export const Login: React.FC = () => {
await handleConnect(server.address);
}}
/>
</View>
<View className='px-4 pb-2'>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);

View File

@@ -3,7 +3,7 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { scaleSize } from "@/utils/scaleSize";
import type { SavedServerAccount } from "@/utils/secureCredentials";
interface TVAccountCardProps {
@@ -85,7 +85,7 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
@@ -98,9 +98,9 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
borderRadius: scaleSize(16),
paddingHorizontal: scaleSize(24),
paddingVertical: scaleSize(20),
flexDirection: "row",
alignItems: "center",
}}
@@ -108,23 +108,23 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
{/* Avatar */}
<View
style={{
width: 56,
height: 56,
width: scaleSize(56),
height: scaleSize(56),
backgroundColor: "#404040",
borderRadius: 28,
borderRadius: scaleSize(28),
alignItems: "center",
justifyContent: "center",
marginRight: 20,
marginRight: scaleSize(20),
}}
>
<Ionicons name='person' size={28} color='white' />
<Ionicons name='person' size={scaleSize(28)} color='white' />
</View>
{/* Account Info */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontSize: scaleSize(22),
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -133,9 +133,9 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</Text>
<Text
style={{
fontSize: 16,
fontSize: scaleSize(16),
color: "#9CA3AF",
marginTop: 4,
marginTop: scaleSize(4),
}}
>
{getSecurityText()}
@@ -143,7 +143,11 @@ export const TVAccountCard: React.FC<TVAccountCardProps> = ({
</View>
{/* Security Icon */}
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
<Ionicons
name={getSecurityIcon()}
size={scaleSize(24)}
color='#fff'
/>
</View>
</Animated.View>
</Pressable>

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